İlk commit: Stepstead backend (FastAPI)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
ea049b8ccd
45 changed files with 6751 additions and 0 deletions
0
routers/__init__.py
Normal file
0
routers/__init__.py
Normal file
267
routers/admin.py
Normal file
267
routers/admin.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""
|
||||
Admin — asset upload (harita görselleri vb.).
|
||||
|
||||
Sadece ADMIN_EMAILS listesindeki hesaplar kullanabilir (oyun JWT'si ile).
|
||||
Dosyalar /var/www/stepstead.com/assets/uploads/ altına yazılır ve
|
||||
https://stepstead.com/assets/uploads/<ad> ile servis edilir.
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import LEGACY_ADMIN_EMAILS as ADMIN_EMAILS
|
||||
from deps import PERMISSIONS, require_perm
|
||||
from models import Role, User, get_db
|
||||
from security import hash_password
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
UPLOAD_DIR = Path("/var/www/stepstead.com/assets/uploads")
|
||||
MAX_SIZE = 30 * 1024 * 1024 # 30 MB
|
||||
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".json", ".zip"}
|
||||
|
||||
|
||||
def _clean_name(s: str) -> str:
|
||||
"""Dosya adı temizliği: Türkçe dahil Unicode harf/rakam korunur (NFC'ye
|
||||
normalize edilir — iOS NFD gönderir), sadece tehlikeli karakterler atılır."""
|
||||
s = unicodedata.normalize("NFC", s)
|
||||
s = re.sub(r"[/\\\x00-\x1f]", "", s)
|
||||
return s.strip().strip(".")[:80]
|
||||
|
||||
|
||||
def _url(name: str) -> str:
|
||||
return "https://stepstead.com/assets/uploads/" + quote(name)
|
||||
|
||||
|
||||
# Geriye dönük: eski kod yolu; artık rol tabanlı require_perm kullanılıyor.
|
||||
def _require_admin(user: User) -> None:
|
||||
if user.email not in ADMIN_EMAILS:
|
||||
raise HTTPException(403, "Yetkin yok")
|
||||
|
||||
|
||||
class UploadOut(BaseModel):
|
||||
filename: str
|
||||
url: str
|
||||
size: int
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadOut)
|
||||
async def upload_asset(
|
||||
file: UploadFile,
|
||||
target_name: str | None = Form(None),
|
||||
user: User = Depends(require_perm("upload")),
|
||||
):
|
||||
orig = Path(file.filename or "dosya").name
|
||||
ext = Path(orig).suffix.lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
raise HTTPException(400, f"İzinli uzantılar: {', '.join(sorted(ALLOWED_EXT))}")
|
||||
if target_name:
|
||||
# Asset listesinden yükleme: "up_<asset adı>.<ext>" — up_ öneki
|
||||
# "buradan geldi, boyut/uygunluk kontrolü bekliyor" demek.
|
||||
clean = _clean_name(target_name)
|
||||
if not clean:
|
||||
raise HTTPException(400, "Geçersiz hedef ad")
|
||||
name = f"up_{clean}{ext}"
|
||||
else:
|
||||
stem = _clean_name(Path(orig).stem) or "dosya"
|
||||
name = f"{stem}{ext}"
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise HTTPException(400, "Dosya çok büyük (max 30 MB)")
|
||||
if not data:
|
||||
raise HTTPException(400, "Boş dosya")
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(UPLOAD_DIR / name).write_bytes(data)
|
||||
return UploadOut(filename=name, url=_url(name), size=len(data))
|
||||
|
||||
|
||||
class FileRow(BaseModel):
|
||||
filename: str
|
||||
url: str
|
||||
size: int
|
||||
|
||||
|
||||
@router.get("/uploads", response_model=list[FileRow])
|
||||
async def list_uploads(user: User = Depends(require_perm("upload"))):
|
||||
out: list[FileRow] = []
|
||||
if UPLOAD_DIR.exists():
|
||||
for p in sorted(UPLOAD_DIR.iterdir()):
|
||||
if p.is_file():
|
||||
out.append(FileRow(filename=p.name, url=_url(p.name),
|
||||
size=p.stat().st_size))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kullanıcı & rol yönetimi — sadece "users" izni (admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RoleOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
permissions: list[str]
|
||||
is_system: bool
|
||||
user_count: int = 0
|
||||
|
||||
|
||||
class RoleIn(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=40)
|
||||
permissions: list[str] = []
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
display_name: str | None
|
||||
role_id: int | None
|
||||
role_name: str | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
class UserCreateIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
display_name: str | None = Field(default=None, max_length=64)
|
||||
role_id: int | None = None
|
||||
|
||||
|
||||
class UserUpdateIn(BaseModel):
|
||||
role_id: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
def _valid_perms(perms: list[str]) -> list[str]:
|
||||
bad = [p for p in perms if p not in PERMISSIONS and p != "*"]
|
||||
if bad:
|
||||
raise HTTPException(400, f"Bilinmeyen izin: {', '.join(bad)}")
|
||||
return sorted(set(perms))
|
||||
|
||||
|
||||
@router.get("/permissions")
|
||||
async def list_permissions(user: User = Depends(require_perm("users"))):
|
||||
return PERMISSIONS
|
||||
|
||||
|
||||
@router.get("/roles", response_model=list[RoleOut])
|
||||
async def list_roles(user: User = Depends(require_perm("users")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
counts = dict((await db.execute(
|
||||
select(User.role_id, func.count()).where(User.role_id.isnot(None)).group_by(User.role_id)
|
||||
)).all())
|
||||
roles = (await db.execute(select(Role).order_by(Role.id))).scalars().all()
|
||||
return [RoleOut(id=r.id, name=r.name, permissions=list(r.permissions or []),
|
||||
is_system=r.is_system, user_count=counts.get(r.id, 0)) for r in roles]
|
||||
|
||||
|
||||
@router.post("/roles", response_model=RoleOut)
|
||||
async def create_role(data: RoleIn, user: User = Depends(require_perm("users")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
exists = (await db.execute(select(Role).where(Role.name == data.name))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(409, "Bu isimde rol zaten var")
|
||||
role = Role(name=data.name, permissions=_valid_perms(data.permissions))
|
||||
db.add(role)
|
||||
await db.commit()
|
||||
await db.refresh(role)
|
||||
return RoleOut(id=role.id, name=role.name, permissions=list(role.permissions),
|
||||
is_system=role.is_system)
|
||||
|
||||
|
||||
@router.post("/roles/{role_id}", response_model=RoleOut)
|
||||
async def update_role(role_id: int, data: RoleIn,
|
||||
user: User = Depends(require_perm("users")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none()
|
||||
if not role:
|
||||
raise HTTPException(404, "Rol yok")
|
||||
if role.is_system:
|
||||
raise HTTPException(400, "Sistem rolü (admin) değiştirilemez")
|
||||
role.name = data.name
|
||||
role.permissions = _valid_perms(data.permissions)
|
||||
await db.commit()
|
||||
return RoleOut(id=role.id, name=role.name, permissions=list(role.permissions),
|
||||
is_system=role.is_system)
|
||||
|
||||
|
||||
@router.post("/roles/{role_id}/delete")
|
||||
async def delete_role(role_id: int, user: User = Depends(require_perm("users")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none()
|
||||
if not role:
|
||||
raise HTTPException(404, "Rol yok")
|
||||
if role.is_system:
|
||||
raise HTTPException(400, "Sistem rolü silinemez")
|
||||
in_use = (await db.execute(select(func.count()).where(User.role_id == role_id))).scalar()
|
||||
if in_use:
|
||||
raise HTTPException(400, f"Rol {in_use} kullanıcıda atanmış — önce onları değiştir")
|
||||
await db.delete(role)
|
||||
await db.commit()
|
||||
return {"deleted": role_id}
|
||||
|
||||
|
||||
def _user_out(u: User, role_name: str | None) -> AdminUserOut:
|
||||
return AdminUserOut(id=u.id, email=u.email, display_name=u.display_name,
|
||||
role_id=u.role_id, role_name=role_name,
|
||||
is_active=u.is_active, created_at=u.created_at.isoformat())
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserOut])
|
||||
async def list_users(user: User = Depends(require_perm("users")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(
|
||||
select(User, Role.name).outerjoin(Role, Role.id == User.role_id).order_by(User.id)
|
||||
)).all()
|
||||
return [_user_out(u, rn) for u, rn in rows]
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserOut)
|
||||
async def create_user(data: UserCreateIn, user: User = Depends(require_perm("users")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
email = str(data.email).lower()
|
||||
exists = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(409, "Bu e-posta zaten kayıtlı")
|
||||
role_name = None
|
||||
if data.role_id is not None:
|
||||
role = (await db.execute(select(Role).where(Role.id == data.role_id))).scalar_one_or_none()
|
||||
if not role:
|
||||
raise HTTPException(400, "Rol yok")
|
||||
role_name = role.name
|
||||
u = User(email=email, password_hash=hash_password(data.password),
|
||||
display_name=data.display_name, email_verified=True, role_id=data.role_id)
|
||||
db.add(u)
|
||||
await db.commit()
|
||||
await db.refresh(u)
|
||||
return _user_out(u, role_name)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}", response_model=AdminUserOut)
|
||||
async def update_user(user_id: int, data: UserUpdateIn,
|
||||
user: User = Depends(require_perm("users")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
u = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||
if not u:
|
||||
raise HTTPException(404, "Kullanıcı yok")
|
||||
if "role_id" in data.model_fields_set: # role_id:null gönderilirse rol kaldırılır
|
||||
if data.role_id is not None:
|
||||
role = (await db.execute(select(Role).where(Role.id == data.role_id))).scalar_one_or_none()
|
||||
if not role:
|
||||
raise HTTPException(400, "Rol yok")
|
||||
u.role_id = data.role_id
|
||||
if data.is_active is not None:
|
||||
if u.id == user.id and not data.is_active:
|
||||
raise HTTPException(400, "Kendini pasifleştiremezsin")
|
||||
u.is_active = data.is_active
|
||||
await db.commit()
|
||||
role_name = None
|
||||
if u.role_id:
|
||||
role_name = (await db.execute(select(Role.name).where(Role.id == u.role_id))).scalar_one_or_none()
|
||||
return _user_out(u, role_name)
|
||||
180
routers/auth.py
Normal file
180
routers/auth.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from deps import get_current_user, get_user_perms
|
||||
from email_utils import password_reset_template, send_email, verify_email_template
|
||||
from models import PasswordResetToken, User, get_db
|
||||
from security import create_access_token, hash_password, verify_password
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
display_name: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in_minutes: int = settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
display_name: str | None
|
||||
email_verified: bool
|
||||
is_admin: bool
|
||||
role: str | None = None
|
||||
permissions: list[str] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RequestResetIn(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ConfirmResetIn(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def register(data: RegisterIn, db: AsyncSession = Depends(get_db)):
|
||||
exists = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(409, "Bu e-posta adresi zaten kayıtlı")
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
user = User(
|
||||
email=str(data.email).lower(),
|
||||
password_hash=hash_password(data.password),
|
||||
display_name=data.display_name,
|
||||
email_verified=False,
|
||||
email_verify_token=token,
|
||||
email_verify_sent_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
verify_url = f"{settings.APP_URL}/verify-email?token={token}"
|
||||
body, html = verify_email_template(verify_url)
|
||||
try:
|
||||
await send_email(user.email, "Stepstead — E-posta Doğrulama", body, html)
|
||||
except Exception as e:
|
||||
print(f"[email] verify mail failed: {e}")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
async def login(data: LoginIn, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
raise HTTPException(401, "Geçersiz e-posta veya şifre")
|
||||
if not user.is_active:
|
||||
raise HTTPException(403, "Hesap askıya alınmış")
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return TokenOut(access_token=create_access_token(user.id, {"email": user.email}))
|
||||
|
||||
|
||||
@router.get("/verify-email")
|
||||
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email_verify_token == token))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(400, "Geçersiz veya kullanılmış token")
|
||||
|
||||
sent_at = user.email_verify_sent_at
|
||||
if sent_at and (datetime.now(timezone.utc) - sent_at) > timedelta(hours=24):
|
||||
raise HTTPException(400, "Token süresi dolmuş")
|
||||
|
||||
user.email_verified = True
|
||||
user.email_verify_token = None
|
||||
await db.commit()
|
||||
return {"status": "ok", "message": "E-posta doğrulandı"}
|
||||
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification(data: RequestResetIn, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if not user:
|
||||
return {"status": "ok"}
|
||||
if user.email_verified:
|
||||
return {"status": "already_verified"}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.email_verify_token = token
|
||||
user.email_verify_sent_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
body, html = verify_email_template(f"{settings.APP_URL}/verify-email?token={token}")
|
||||
await send_email(user.email, "Stepstead — E-posta Doğrulama", body, html)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/request-password-reset")
|
||||
async def request_password_reset(data: RequestResetIn, db: AsyncSession = Depends(get_db)):
|
||||
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
|
||||
if not user:
|
||||
return {"status": "ok"}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.add(PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
))
|
||||
await db.commit()
|
||||
body, html = password_reset_template(f"{settings.APP_URL}/reset-password?token={token}")
|
||||
try:
|
||||
await send_email(user.email, "Stepstead — Şifre Sıfırlama", body, html)
|
||||
except Exception as e:
|
||||
print(f"[email] reset mail failed: {e}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(data: ConfirmResetIn, db: AsyncSession = Depends(get_db)):
|
||||
rt = (await db.execute(select(PasswordResetToken).where(PasswordResetToken.token == data.token))).scalar_one_or_none()
|
||||
if not rt or rt.used_at is not None:
|
||||
raise HTTPException(400, "Geçersiz veya kullanılmış token")
|
||||
if rt.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(400, "Token süresi dolmuş")
|
||||
|
||||
user = (await db.execute(select(User).where(User.id == rt.user_id))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(400, "Kullanıcı bulunamadı")
|
||||
|
||||
user.password_hash = hash_password(data.new_password)
|
||||
rt.used_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"status": "ok", "message": "Şifre değiştirildi"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
out = UserOut.model_validate(user, from_attributes=True)
|
||||
perms = await get_user_perms(user, db)
|
||||
out.permissions = sorted(perms)
|
||||
if user.role_id:
|
||||
from models import Role
|
||||
out.role = (await db.execute(select(Role.name).where(Role.id == user.role_id))).scalar_one_or_none()
|
||||
elif "*" in perms:
|
||||
out.role = "admin"
|
||||
return out
|
||||
90
routers/character.py
Normal file
90
routers/character.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from deps import get_current_user
|
||||
from models import Character, Skill, User, get_db
|
||||
|
||||
router = APIRouter(prefix="/character", tags=["character"])
|
||||
|
||||
|
||||
class CharacterCreateIn(BaseModel):
|
||||
name: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_]+$")
|
||||
|
||||
|
||||
class SkillOut(BaseModel):
|
||||
skill_name: str
|
||||
xp: int
|
||||
level: int
|
||||
|
||||
|
||||
class CharacterOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
level: int
|
||||
total_xp: int
|
||||
gold: int
|
||||
step_balance: int
|
||||
step_lifetime: int
|
||||
location_code: Optional[str] = None
|
||||
location_name: Optional[str] = None
|
||||
has_pending_event: bool = False
|
||||
skills: list[SkillOut]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
async def _with_location(db: AsyncSession, char: Character) -> CharacterOut:
|
||||
out = CharacterOut.model_validate(char, from_attributes=True)
|
||||
out.has_pending_event = char.pending_event is not None
|
||||
if char.location_id is not None:
|
||||
row = (await db.execute(text(
|
||||
"SELECT code, name FROM locations WHERE id = :i"
|
||||
), {"i": char.location_id})).first()
|
||||
if row:
|
||||
out.location_code, out.location_name = row[0], row[1]
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", response_model=CharacterOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_character(data: CharacterCreateIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
exists = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if exists:
|
||||
raise HTTPException(409, "Zaten bir karakterin var")
|
||||
|
||||
name_taken = (await db.execute(select(Character).where(Character.name == data.name))).scalar_one_or_none()
|
||||
if name_taken:
|
||||
raise HTTPException(409, "Bu isim alınmış")
|
||||
|
||||
char = Character(user_id=user.id, name=data.name)
|
||||
db.add(char)
|
||||
await db.flush()
|
||||
|
||||
for skill_name in settings.SKILL_NAMES:
|
||||
db.add(Skill(character_id=char.id, skill_name=skill_name, xp=0, level=1))
|
||||
|
||||
# Başlangıç lokasyonu: köy. Not: başlangıç tool'u YOK —
|
||||
# ilk tool'lar köyde elle toplanan taş/sopa/sarmaşıktan craft edilir.
|
||||
start = (await db.execute(text(
|
||||
"SELECT id FROM locations WHERE type = 'village' ORDER BY id LIMIT 1"
|
||||
))).first()
|
||||
if start:
|
||||
char.location_id = start[0]
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(char, ["skills"])
|
||||
return await _with_location(db, char)
|
||||
|
||||
|
||||
@router.get("/me", response_model=CharacterOut)
|
||||
async def my_character(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok, önce oluştur")
|
||||
await db.refresh(char, ["skills"])
|
||||
return await _with_location(db, char)
|
||||
296
routers/chars.py
Normal file
296
routers/chars.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""
|
||||
Karakterler — sınıf (class) + skill tanımları ("chars" izni).
|
||||
Sınıf: ne yapar, rolü. Skill: max level, ne yaptığı, bağlı sınıf.
|
||||
WAF nedeniyle sadece GET/POST.
|
||||
GET /chars/classes → sınıf listesi
|
||||
POST /chars/classes → yeni sınıf
|
||||
POST /chars/classes/{id} → güncelle
|
||||
POST /chars/classes/{id}/delete→ sil
|
||||
GET /chars/skills → skill listesi (sınıf adıyla)
|
||||
POST /chars/skills → yeni skill
|
||||
POST /chars/skills/{id} → güncelle
|
||||
POST /chars/skills/{id}/delete → sil
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import require_perm, require_panel, get_user_perms, has_perm
|
||||
from models import User, get_db
|
||||
|
||||
router = APIRouter(prefix="/chars", tags=["chars"])
|
||||
|
||||
|
||||
def _author(user: User) -> str:
|
||||
return (user.display_name or "").strip() or user.email
|
||||
|
||||
|
||||
async def _assert_owner_or_admin(db: AsyncSession, user: User, table: str, row_id: int):
|
||||
perms = await get_user_perms(user, db)
|
||||
if has_perm(perms, "*"):
|
||||
return
|
||||
owner = (await db.execute(text(
|
||||
f"SELECT author_id FROM {table} WHERE id=:id"
|
||||
), {"id": row_id})).scalar_one_or_none()
|
||||
if owner is None:
|
||||
raise HTTPException(404, "Kayıt yok")
|
||||
if owner != user.id:
|
||||
raise HTTPException(403, "Sadece kendi kaydını düzenleyebilir/silebilirsin")
|
||||
|
||||
|
||||
# ---------------- Sınıflar ----------------
|
||||
|
||||
class ClassIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
summary: str | None = Field(default=None, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=20000)
|
||||
notes: str | None = Field(default=None, max_length=20000)
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ClassOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
summary: str | None
|
||||
description: str | None
|
||||
notes: str | None
|
||||
sort_order: int
|
||||
author: str | None
|
||||
author_id: int | None
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _crow(r) -> ClassOut:
|
||||
d = dict(r)
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
return ClassOut(**d)
|
||||
|
||||
|
||||
@router.get("/classes", response_model=list[ClassOut])
|
||||
async def list_classes(user: User = Depends(require_panel), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
"SELECT id,name,summary,description,notes,sort_order,author,author_id,updated_at "
|
||||
"FROM game_classes ORDER BY sort_order,name"
|
||||
))).mappings().all()
|
||||
return [_crow(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/classes", response_model=ClassOut)
|
||||
async def add_class(c: ClassIn, user: User = Depends(require_perm("chars")), db: AsyncSession = Depends(get_db)):
|
||||
row = (await db.execute(text(
|
||||
"INSERT INTO game_classes(name,summary,description,notes,sort_order,author,author_id) "
|
||||
"VALUES(:name,:summary,:description,:notes,:so,:author,:aid) "
|
||||
"RETURNING id,name,summary,description,notes,sort_order,author,author_id,updated_at"
|
||||
), {"name": c.name, "summary": c.summary, "description": c.description, "notes": c.notes,
|
||||
"so": c.sort_order, "author": _author(user), "aid": user.id})).mappings().one()
|
||||
await db.commit()
|
||||
return _crow(row)
|
||||
|
||||
|
||||
@router.post("/classes/{class_id}", response_model=ClassOut)
|
||||
async def update_class(class_id: int, c: ClassIn, user: User = Depends(require_perm("chars")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "game_classes", class_id)
|
||||
row = (await db.execute(text(
|
||||
"UPDATE game_classes SET name=:name,summary=:summary,description=:description,notes=:notes,"
|
||||
"sort_order=:so,updated_at=now() WHERE id=:id "
|
||||
"RETURNING id,name,summary,description,notes,sort_order,author,author_id,updated_at"
|
||||
), {"id": class_id, "name": c.name, "summary": c.summary, "description": c.description,
|
||||
"notes": c.notes, "so": c.sort_order})).mappings().one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Sınıf yok")
|
||||
await db.commit()
|
||||
return _crow(row)
|
||||
|
||||
|
||||
@router.post("/classes/{class_id}/delete")
|
||||
async def delete_class(class_id: int, user: User = Depends(require_perm("chars")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "game_classes", class_id)
|
||||
res = await db.execute(text("DELETE FROM game_classes WHERE id=:id"), {"id": class_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Sınıf yok")
|
||||
return {"deleted": class_id}
|
||||
|
||||
|
||||
# ---------------- Skiller ----------------
|
||||
|
||||
class SkillIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
max_level: int | None = Field(default=None, ge=0, le=1000)
|
||||
effect: str | None = Field(default=None, max_length=20000)
|
||||
description: str | None = Field(default=None, max_length=20000)
|
||||
class_id: int | None = None
|
||||
notes: str | None = Field(default=None, max_length=20000)
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class SkillOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
max_level: int | None
|
||||
effect: str | None
|
||||
description: str | None
|
||||
class_id: int | None
|
||||
class_name: str | None
|
||||
notes: str | None
|
||||
sort_order: int
|
||||
author: str | None
|
||||
author_id: int | None
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _srow(r) -> SkillOut:
|
||||
d = dict(r)
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
return SkillOut(**d)
|
||||
|
||||
|
||||
async def _check_class(db: AsyncSession, class_id: int | None) -> None:
|
||||
if class_id is None:
|
||||
return
|
||||
ok = (await db.execute(text("SELECT 1 FROM game_classes WHERE id=:id"), {"id": class_id})).scalar()
|
||||
if not ok:
|
||||
raise HTTPException(400, "Sınıf bulunamadı")
|
||||
|
||||
|
||||
@router.get("/skills", response_model=list[SkillOut])
|
||||
async def list_skills(user: User = Depends(require_panel), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
"SELECT s.id,s.name,s.max_level,s.effect,s.description,s.class_id,c.name AS class_name,"
|
||||
"s.notes,s.sort_order,s.author,s.author_id,s.updated_at "
|
||||
"FROM game_skills s LEFT JOIN game_classes c ON c.id=s.class_id "
|
||||
"ORDER BY s.sort_order,s.name"
|
||||
))).mappings().all()
|
||||
return [_srow(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/skills", response_model=SkillOut)
|
||||
async def add_skill(s: SkillIn, user: User = Depends(require_perm("chars")), db: AsyncSession = Depends(get_db)):
|
||||
await _check_class(db, s.class_id)
|
||||
sid = (await db.execute(text(
|
||||
"INSERT INTO game_skills(name,max_level,effect,description,class_id,notes,sort_order,author,author_id) "
|
||||
"VALUES(:name,:mx,:effect,:description,:cid,:notes,:so,:author,:aid) RETURNING id"
|
||||
), {"name": s.name, "mx": s.max_level, "effect": s.effect, "description": s.description,
|
||||
"cid": s.class_id, "notes": s.notes, "so": s.sort_order,
|
||||
"author": _author(user), "aid": user.id})).scalar_one()
|
||||
await db.commit()
|
||||
return await _get_skill(db, sid)
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}", response_model=SkillOut)
|
||||
async def update_skill(skill_id: int, s: SkillIn, user: User = Depends(require_perm("chars")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "game_skills", skill_id)
|
||||
await _check_class(db, s.class_id)
|
||||
res = await db.execute(text(
|
||||
"UPDATE game_skills SET name=:name,max_level=:mx,effect=:effect,description=:description,"
|
||||
"class_id=:cid,notes=:notes,sort_order=:so,updated_at=now() WHERE id=:id"
|
||||
), {"id": skill_id, "name": s.name, "mx": s.max_level, "effect": s.effect,
|
||||
"description": s.description, "cid": s.class_id, "notes": s.notes, "so": s.sort_order})
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Skill yok")
|
||||
await db.commit()
|
||||
return await _get_skill(db, skill_id)
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/delete")
|
||||
async def delete_skill(skill_id: int, user: User = Depends(require_perm("chars")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "game_skills", skill_id)
|
||||
res = await db.execute(text("DELETE FROM game_skills WHERE id=:id"), {"id": skill_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Skill yok")
|
||||
return {"deleted": skill_id}
|
||||
|
||||
|
||||
async def _get_skill(db: AsyncSession, skill_id: int) -> SkillOut:
|
||||
row = (await db.execute(text(
|
||||
"SELECT s.id,s.name,s.max_level,s.effect,s.description,s.class_id,c.name AS class_name,"
|
||||
"s.notes,s.sort_order,s.author,s.author_id,s.updated_at "
|
||||
"FROM game_skills s LEFT JOIN game_classes c ON c.id=s.class_id WHERE s.id=:id"
|
||||
), {"id": skill_id})).mappings().one()
|
||||
return _srow(row)
|
||||
|
||||
|
||||
# ---------------- Irklar ----------------
|
||||
|
||||
class RaceIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
summary: str | None = Field(default=None, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=20000)
|
||||
traits: str | None = Field(default=None, max_length=20000)
|
||||
notes: str | None = Field(default=None, max_length=20000)
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class RaceOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
summary: str | None
|
||||
description: str | None
|
||||
traits: str | None
|
||||
notes: str | None
|
||||
sort_order: int
|
||||
author: str | None
|
||||
author_id: int | None
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _rrow(r) -> RaceOut:
|
||||
d = dict(r)
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
return RaceOut(**d)
|
||||
|
||||
|
||||
_RACE_COLS = "id,name,summary,description,traits,notes,sort_order,author,author_id,updated_at"
|
||||
|
||||
|
||||
@router.get("/races", response_model=list[RaceOut])
|
||||
async def list_races(user: User = Depends(require_panel), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
f"SELECT {_RACE_COLS} FROM game_races ORDER BY sort_order,name"
|
||||
))).mappings().all()
|
||||
return [_rrow(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/races", response_model=RaceOut)
|
||||
async def add_race(r: RaceIn, user: User = Depends(require_perm("chars")), db: AsyncSession = Depends(get_db)):
|
||||
row = (await db.execute(text(
|
||||
"INSERT INTO game_races(name,summary,description,traits,notes,sort_order,author,author_id) "
|
||||
"VALUES(:name,:summary,:description,:traits,:notes,:so,:author,:aid) "
|
||||
f"RETURNING {_RACE_COLS}"
|
||||
), {"name": r.name, "summary": r.summary, "description": r.description, "traits": r.traits,
|
||||
"notes": r.notes, "so": r.sort_order, "author": _author(user), "aid": user.id})).mappings().one()
|
||||
await db.commit()
|
||||
return _rrow(row)
|
||||
|
||||
|
||||
@router.post("/races/{race_id}", response_model=RaceOut)
|
||||
async def update_race(race_id: int, r: RaceIn, user: User = Depends(require_perm("chars")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "game_races", race_id)
|
||||
row = (await db.execute(text(
|
||||
"UPDATE game_races SET name=:name,summary=:summary,description=:description,traits=:traits,"
|
||||
"notes=:notes,sort_order=:so,updated_at=now() WHERE id=:id "
|
||||
f"RETURNING {_RACE_COLS}"
|
||||
), {"id": race_id, "name": r.name, "summary": r.summary, "description": r.description,
|
||||
"traits": r.traits, "notes": r.notes, "so": r.sort_order})).mappings().one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Irk yok")
|
||||
await db.commit()
|
||||
return _rrow(row)
|
||||
|
||||
|
||||
@router.post("/races/{race_id}/delete")
|
||||
async def delete_race(race_id: int, user: User = Depends(require_perm("chars")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "game_races", race_id)
|
||||
res = await db.execute(text("DELETE FROM game_races WHERE id=:id"), {"id": race_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Irk yok")
|
||||
return {"deleted": race_id}
|
||||
251
routers/craft.py
Normal file
251
routers/craft.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""
|
||||
Crafting: tarifleri listele, üret.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import get_current_user
|
||||
from game_logic import character_level_from_total_xp, skill_level_from_xp
|
||||
from models import Character, Skill, User, get_db
|
||||
|
||||
router = APIRouter(prefix="/craft", tags=["craft"])
|
||||
|
||||
|
||||
class IngredientOut(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
qty: int
|
||||
have: int
|
||||
|
||||
|
||||
class RecipeOut(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
output_item_code: str
|
||||
output_item_name: str
|
||||
output_qty: int
|
||||
skill: str
|
||||
required_level: int
|
||||
xp_reward: int
|
||||
step_cost: int
|
||||
ingredients: list[IngredientOut]
|
||||
unlocked: bool
|
||||
craftable: bool
|
||||
|
||||
|
||||
async def _char(db: AsyncSession, user: User) -> Character:
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
return char
|
||||
|
||||
|
||||
async def _inventory_map(db: AsyncSession, char_id: int) -> dict[str, int]:
|
||||
rows = (await db.execute(text("""
|
||||
SELECT i.code, SUM(inv.qty)::int AS qty
|
||||
FROM inventory inv JOIN items i ON i.id = inv.item_id
|
||||
WHERE inv.character_id = :cid GROUP BY i.code
|
||||
"""), {"cid": char_id})).all()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
async def _skill_levels(db: AsyncSession, char_id: int) -> dict[str, int]:
|
||||
rows = (await db.execute(text(
|
||||
"SELECT skill_name, level FROM skills WHERE character_id = :cid"
|
||||
), {"cid": char_id})).all()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
@router.get("/recipes", response_model=list[RecipeOut])
|
||||
async def list_recipes(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
inv = await _inventory_map(db, char.id)
|
||||
skl = await _skill_levels(db, char.id)
|
||||
bal = char.step_balance
|
||||
|
||||
rows = (await db.execute(text("""
|
||||
SELECT r.id, r.code, r.name, r.output_item_id, r.output_qty,
|
||||
r.skill, r.required_level, r.xp_reward, r.step_cost, r.ingredients,
|
||||
oi.code, oi.name
|
||||
FROM recipes r JOIN items oi ON oi.id = r.output_item_id
|
||||
ORDER BY r.skill, r.required_level
|
||||
"""))).all()
|
||||
|
||||
out: list[RecipeOut] = []
|
||||
for r in rows:
|
||||
ings: list[IngredientOut] = []
|
||||
all_ok = True
|
||||
for ing in r[9]: # JSONB
|
||||
code = ing["item_code"]
|
||||
qty = int(ing["qty"])
|
||||
item_row = (await db.execute(text("SELECT name FROM items WHERE code=:c"), {"c": code})).first()
|
||||
have = inv.get(code, 0)
|
||||
if have < qty:
|
||||
all_ok = False
|
||||
ings.append(IngredientOut(item_code=code, item_name=item_row[0] if item_row else code, qty=qty, have=have))
|
||||
|
||||
unlocked = skl.get(r[5], 1) >= r[6]
|
||||
affordable_steps = bal >= r[8]
|
||||
craftable = unlocked and all_ok and affordable_steps
|
||||
|
||||
out.append(RecipeOut(
|
||||
id=r[0], code=r[1], name=r[2],
|
||||
output_item_code=r[10], output_item_name=r[11], output_qty=r[4],
|
||||
skill=r[5], required_level=r[6], xp_reward=r[7], step_cost=r[8],
|
||||
ingredients=ings, unlocked=unlocked, craftable=craftable,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
class CraftIn(BaseModel):
|
||||
recipe_code: str
|
||||
qty: int = Field(1, ge=1, le=50)
|
||||
|
||||
|
||||
class CraftOut(BaseModel):
|
||||
output_item_code: str
|
||||
output_item_name: str
|
||||
output_qty: int
|
||||
skill: str
|
||||
xp_gained: int
|
||||
skill_level: int
|
||||
skill_level_up: bool
|
||||
character_level: int
|
||||
character_level_up: bool
|
||||
step_balance: int
|
||||
|
||||
|
||||
@router.post("", response_model=CraftOut)
|
||||
async def craft(c: CraftIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
|
||||
rcp = (await db.execute(text("""
|
||||
SELECT id, output_item_id, output_qty, skill, required_level, xp_reward, step_cost, ingredients
|
||||
FROM recipes WHERE code = :c
|
||||
"""), {"c": c.recipe_code})).first()
|
||||
if not rcp:
|
||||
raise HTTPException(404, "Tarif yok")
|
||||
|
||||
skill_name = rcp[3]
|
||||
required_level = rcp[4]
|
||||
step_cost_per = rcp[6]
|
||||
xp_per = rcp[5]
|
||||
ingredients = rcp[7]
|
||||
|
||||
# Skill seviye
|
||||
skill = (await db.execute(
|
||||
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == skill_name)
|
||||
)).scalar_one_or_none()
|
||||
if skill is None:
|
||||
skill = Skill(character_id=char.id, skill_name=skill_name, xp=0, level=1)
|
||||
db.add(skill)
|
||||
await db.flush()
|
||||
if skill.level < required_level:
|
||||
raise HTTPException(400, f"{skill_name} {required_level} gerekli (mevcut {skill.level})")
|
||||
|
||||
# Toplam adım
|
||||
total_step = step_cost_per * c.qty
|
||||
if char.step_balance < total_step:
|
||||
raise HTTPException(400, f"Yetersiz adım: {total_step} gerekli, {char.step_balance} var")
|
||||
|
||||
# Malzeme yeterli mi
|
||||
inv = await _inventory_map(db, char.id)
|
||||
for ing in ingredients:
|
||||
need = int(ing["qty"]) * c.qty
|
||||
have = inv.get(ing["item_code"], 0)
|
||||
if have < need:
|
||||
raise HTTPException(400, f"{ing['item_code']} yetersiz: {need} gerekli, {have} var")
|
||||
|
||||
# Malzemeleri tüket (FIFO slotlardan)
|
||||
for ing in ingredients:
|
||||
need = int(ing["qty"]) * c.qty
|
||||
item_id_row = (await db.execute(text("SELECT id FROM items WHERE code=:c"), {"c": ing["item_code"]})).first()
|
||||
if not item_id_row:
|
||||
raise HTTPException(500, f"Item bulunamadı: {ing['item_code']}")
|
||||
item_id = item_id_row[0]
|
||||
stacks = (await db.execute(text("""
|
||||
SELECT id, qty FROM inventory
|
||||
WHERE character_id = :cid AND item_id = :iid
|
||||
ORDER BY slot
|
||||
"""), {"cid": char.id, "iid": item_id})).all()
|
||||
remaining = need
|
||||
for st in stacks:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(remaining, st[1])
|
||||
if take == st[1]:
|
||||
await db.execute(text("DELETE FROM inventory WHERE id = :id"), {"id": st[0]})
|
||||
else:
|
||||
await db.execute(text("UPDATE inventory SET qty = qty - :t WHERE id = :id"), {"t": take, "id": st[0]})
|
||||
remaining -= take
|
||||
if remaining > 0:
|
||||
raise HTTPException(500, "İç tutarsızlık: malzeme eksildi ama bulunamadı")
|
||||
|
||||
# Output ekle (stack ya da yeni slot)
|
||||
out_qty = int(rcp[2]) * c.qty
|
||||
out_item_id = rcp[1]
|
||||
out_row = (await db.execute(text("SELECT code, name, stack_max FROM items WHERE id = :i"), {"i": out_item_id})).first()
|
||||
out_code = out_row[0]
|
||||
out_name = out_row[1]
|
||||
stack_max = int(out_row[2])
|
||||
|
||||
remaining = out_qty
|
||||
# Önce mevcut stack'lere ekle (stack_max'a kadar)
|
||||
existing = (await db.execute(text("""
|
||||
SELECT id, qty FROM inventory WHERE character_id = :cid AND item_id = :iid ORDER BY slot
|
||||
"""), {"cid": char.id, "iid": out_item_id})).all()
|
||||
for ex in existing:
|
||||
if remaining <= 0:
|
||||
break
|
||||
space = stack_max - ex[1]
|
||||
if space <= 0:
|
||||
continue
|
||||
add = min(space, remaining)
|
||||
await db.execute(text("UPDATE inventory SET qty = qty + :a WHERE id = :id"), {"a": add, "id": ex[0]})
|
||||
remaining -= add
|
||||
# Yeni slot(lar)
|
||||
while remaining > 0:
|
||||
add = min(stack_max, remaining)
|
||||
slot_row = (await db.execute(text(
|
||||
"SELECT COALESCE(MAX(slot), -1) + 1 FROM inventory WHERE character_id = :cid"
|
||||
), {"cid": char.id})).first()
|
||||
next_slot = slot_row[0]
|
||||
await db.execute(text("""
|
||||
INSERT INTO inventory (character_id, item_id, qty, slot)
|
||||
VALUES (:cid, :iid, :q, :s)
|
||||
"""), {"cid": char.id, "iid": out_item_id, "q": add, "s": next_slot})
|
||||
remaining -= add
|
||||
|
||||
# XP + adım
|
||||
xp_gain = xp_per * c.qty
|
||||
old_sl = skill.level
|
||||
skill.xp += xp_gain
|
||||
skill.level = skill_level_from_xp(skill.xp)
|
||||
skill_up = skill.level > old_sl
|
||||
|
||||
old_cl = char.level
|
||||
char.total_xp += xp_gain
|
||||
char.level = character_level_from_total_xp(char.total_xp)
|
||||
char_up = char.level > old_cl
|
||||
|
||||
char.step_balance -= total_step
|
||||
char.last_sync_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
return CraftOut(
|
||||
output_item_code=out_code,
|
||||
output_item_name=out_name,
|
||||
output_qty=out_qty,
|
||||
skill=skill_name,
|
||||
xp_gained=xp_gain,
|
||||
skill_level=skill.level,
|
||||
skill_level_up=skill_up,
|
||||
character_level=char.level,
|
||||
character_level_up=char_up,
|
||||
step_balance=char.step_balance,
|
||||
)
|
||||
160
routers/harvest.py
Normal file
160
routers/harvest.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""
|
||||
Envanter + consumable kullanımı + tool tanımları.
|
||||
|
||||
NOT (2026-07-04): Node hasat sistemi kaldırıldı — kaynak toplama artık
|
||||
lokasyon bazlı (routers/travel.py: POST /world/gather). SKILL_TOOLS burada
|
||||
tanımlı kalır, travel.py buradan import eder.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import bindparam, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import get_current_user
|
||||
from models import Character, User, get_db
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["inventory"])
|
||||
|
||||
# Tool şartı: bu skill'lerle toplama için envanterde listedeki tool'lardan biri olmalı.
|
||||
# Foraging (elle toplama) serbest. İlk tool'lar elle toplanan taş/sopa/sarmaşıktan craft edilir.
|
||||
SKILL_TOOLS = {
|
||||
"woodcutting": ["axe_stone", "axe_copper", "axe_iron"],
|
||||
"mining": ["pickaxe_stone", "pickaxe_copper", "pickaxe_iron"],
|
||||
"fishing": ["fishing_rod", "fishing_rod_fine", "fishing_rod_master"],
|
||||
}
|
||||
TOOL_LABEL = {"woodcutting": "Balta", "mining": "Kazma", "fishing": "Olta"}
|
||||
|
||||
|
||||
async def _has_any_tool(db: AsyncSession, char_id: int, codes: list[str]) -> bool:
|
||||
stmt = text("""
|
||||
SELECT 1 FROM inventory inv JOIN items i ON i.id = inv.item_id
|
||||
WHERE inv.character_id = :cid AND i.code IN :codes LIMIT 1
|
||||
""").bindparams(bindparam("codes", expanding=True))
|
||||
row = (await db.execute(stmt, {"cid": char_id, "codes": codes})).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
async def consume_items(db: AsyncSession, char_id: int, item_id: int, qty: int, item_name: str) -> None:
|
||||
"""Envanterden FIFO tüket — market/camp/use-item ortak paterni.
|
||||
Yetersizse HTTPException(400) fırlatır."""
|
||||
stacks = (await db.execute(text(
|
||||
"SELECT id, qty FROM inventory WHERE character_id=:cid AND item_id=:iid ORDER BY slot"
|
||||
), {"cid": char_id, "iid": item_id})).all()
|
||||
have = sum(s[1] for s in stacks)
|
||||
if have < qty:
|
||||
raise HTTPException(400, f"{item_name} yetersiz: {qty} gerekli, {have} var")
|
||||
remaining = qty
|
||||
for st in stacks:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(remaining, st[1])
|
||||
if take == st[1]:
|
||||
await db.execute(text("DELETE FROM inventory WHERE id = :id"), {"id": st[0]})
|
||||
else:
|
||||
await db.execute(text("UPDATE inventory SET qty = qty - :t WHERE id = :id"), {"t": take, "id": st[0]})
|
||||
remaining -= take
|
||||
|
||||
|
||||
class InventoryItem(BaseModel):
|
||||
slot: int
|
||||
item_code: str
|
||||
item_name: str
|
||||
rarity: str
|
||||
qty: int
|
||||
icon: str | None = None
|
||||
item_type: str = "material"
|
||||
effect: dict | None = None
|
||||
|
||||
|
||||
@router.get("/inventory", response_model=list[InventoryItem])
|
||||
async def inventory(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
return []
|
||||
rows = (await db.execute(text("""
|
||||
SELECT inv.slot, i.code, i.name, i.rarity, inv.qty, i.icon, i.type, i.effect
|
||||
FROM inventory inv JOIN items i ON i.id = inv.item_id
|
||||
WHERE inv.character_id = :cid
|
||||
ORDER BY inv.slot
|
||||
"""), {"cid": char.id})).all()
|
||||
return [
|
||||
InventoryItem(slot=r[0], item_code=r[1], item_name=r[2], rarity=r[3], qty=r[4],
|
||||
icon=r[5], item_type=r[6], effect=r[7])
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class UseItemIn(BaseModel):
|
||||
item_code: str
|
||||
qty: int = Field(1, ge=1, le=10)
|
||||
|
||||
|
||||
class UseItemOut(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
qty_used: int
|
||||
effect_type: str
|
||||
effect_amount: int
|
||||
step_balance: int
|
||||
|
||||
|
||||
@router.post("/use-item", response_model=UseItemOut)
|
||||
async def use_item(u: UseItemIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
|
||||
item = (await db.execute(
|
||||
text("SELECT id, name, type, effect FROM items WHERE code = :c"), {"c": u.item_code}
|
||||
)).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item yok")
|
||||
item_id, item_name, item_type, effect = item[0], item[1], item[2], item[3]
|
||||
if item_type != "consumable" or not effect:
|
||||
raise HTTPException(400, f"{item_name} kullanılabilir bir item değil")
|
||||
if effect.get("type") != "steps":
|
||||
raise HTTPException(400, f"Bilinmeyen etki türü: {effect.get('type')}")
|
||||
|
||||
# Envanterde yeterli mi (FIFO tüket — craft ile aynı patern)
|
||||
stacks = (await db.execute(text("""
|
||||
SELECT id, qty FROM inventory
|
||||
WHERE character_id = :cid AND item_id = :iid
|
||||
ORDER BY slot
|
||||
"""), {"cid": char.id, "iid": item_id})).all()
|
||||
have = sum(s[1] for s in stacks)
|
||||
if have < u.qty:
|
||||
raise HTTPException(400, f"{item_name} yetersiz: {u.qty} gerekli, {have} var")
|
||||
|
||||
remaining = u.qty
|
||||
for st in stacks:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(remaining, st[1])
|
||||
if take == st[1]:
|
||||
await db.execute(text("DELETE FROM inventory WHERE id = :id"), {"id": st[0]})
|
||||
else:
|
||||
await db.execute(text("UPDATE inventory SET qty = qty - :t WHERE id = :id"), {"t": take, "id": st[0]})
|
||||
remaining -= take
|
||||
|
||||
# Etki: adım bakiyesi yenile (lifetime'a sayılmaz — gerçek yürüyüş değil)
|
||||
gained = int(effect["amount"]) * u.qty
|
||||
char.step_balance += gained
|
||||
await db.execute(
|
||||
text("INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
|
||||
"VALUES (:cid, :d, 'consumable', true, :r)"),
|
||||
{"cid": char.id, "d": gained, "r": f"{u.item_code} x{u.qty}"},
|
||||
)
|
||||
|
||||
char.last_sync_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
return UseItemOut(
|
||||
item_code=u.item_code,
|
||||
item_name=item_name,
|
||||
qty_used=u.qty,
|
||||
effect_type="steps",
|
||||
effect_amount=gained,
|
||||
step_balance=char.step_balance,
|
||||
)
|
||||
59
routers/leaderboard.py
Normal file
59
routers/leaderboard.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""
|
||||
Liderlik tablosu — adım (lifetime), level ve gold sıralamaları.
|
||||
GET /leaderboard?by=steps|level|gold → top 20 + kendi sıran.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import get_current_user
|
||||
from models import Character, User, get_db
|
||||
|
||||
router = APIRouter(prefix="/leaderboard", tags=["leaderboard"])
|
||||
|
||||
ORDER_COLS = {"steps": "step_lifetime", "level": "total_xp", "gold": "gold"}
|
||||
|
||||
|
||||
class LeaderRow(BaseModel):
|
||||
rank: int
|
||||
name: str
|
||||
level: int
|
||||
value: int # sıralama kriterinin değeri
|
||||
me: bool
|
||||
|
||||
|
||||
class LeaderboardOut(BaseModel):
|
||||
by: str
|
||||
rows: list[LeaderRow]
|
||||
my_rank: int
|
||||
|
||||
|
||||
@router.get("", response_model=LeaderboardOut)
|
||||
async def leaderboard(by: str = "steps",
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
if by not in ORDER_COLS:
|
||||
raise HTTPException(400, "by: steps | level | gold")
|
||||
col = ORDER_COLS[by]
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
|
||||
rows = (await db.execute(text(f"""
|
||||
SELECT rank, name, level, val, id FROM (
|
||||
SELECT id, name, level, {col} AS val,
|
||||
RANK() OVER (ORDER BY {col} DESC, id) AS rank
|
||||
FROM characters
|
||||
) t WHERE rank <= 20 OR id = :me
|
||||
ORDER BY rank
|
||||
"""), {"me": char.id})).all()
|
||||
|
||||
my_rank = 0
|
||||
out: list[LeaderRow] = []
|
||||
for r in rows:
|
||||
me = r[4] == char.id
|
||||
if me:
|
||||
my_rank = int(r[0])
|
||||
if int(r[0]) <= 20:
|
||||
out.append(LeaderRow(rank=int(r[0]), name=r[1], level=int(r[2]), value=int(r[3]), me=me))
|
||||
return LeaderboardOut(by=by, rows=out, my_rank=my_rank)
|
||||
203
routers/lore.py
Normal file
203
routers/lore.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""
|
||||
Lore notları — "lore" izni (admin + loremaster). Hikaye için küçük notlar; sonra derlenir.
|
||||
Hepsi admin-only (GET dahil — özel içerik). WAF nedeniyle sadece GET/POST.
|
||||
GET /lore → notlar (yeniden eskiye)
|
||||
POST /lore → yeni not
|
||||
POST /lore/{id} → güncelle
|
||||
POST /lore/{id}/delete → sil
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import require_perm, get_user_perms, has_perm
|
||||
from models import User, get_db
|
||||
|
||||
router = APIRouter(prefix="/lore", tags=["lore"])
|
||||
|
||||
|
||||
async def _assert_owner_or_admin(db: AsyncSession, user: User, table: str, row_id: int):
|
||||
"""Admin (perm '*') her şeyi düzenler/siler; diğerleri sadece kendi kaydını."""
|
||||
perms = await get_user_perms(user, db)
|
||||
if has_perm(perms, "*"):
|
||||
return
|
||||
owner = (await db.execute(text(
|
||||
f"SELECT author_id FROM {table} WHERE id=:id"
|
||||
), {"id": row_id})).scalar_one_or_none()
|
||||
if owner is None:
|
||||
raise HTTPException(404, "Kayıt yok")
|
||||
if owner != user.id:
|
||||
raise HTTPException(403, "Sadece kendi kaydını düzenleyebilir/silebilirsin")
|
||||
|
||||
|
||||
class NoteIn(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=20000)
|
||||
body_en: str | None = Field(default=None, max_length=20000)
|
||||
tag: str | None = Field(default=None, max_length=60)
|
||||
subject_kind: str | None = Field(default=None, max_length=16) # 'npc' | 'place' | None
|
||||
subject_id: int | None = None
|
||||
subject_label: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class NoteOut(BaseModel):
|
||||
id: int
|
||||
body: str
|
||||
body_en: str | None
|
||||
tag: str | None
|
||||
subject_kind: str | None
|
||||
subject_id: int | None
|
||||
subject_label: str | None
|
||||
author: str | None
|
||||
author_id: int | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
_NOTE_COLS = ("id,body,body_en,tag,subject_kind,subject_id,subject_label,"
|
||||
"author,author_id,created_at,updated_at")
|
||||
|
||||
|
||||
def _author(user: User) -> str:
|
||||
return (user.display_name or "").strip() or user.email
|
||||
|
||||
|
||||
def _row(r) -> NoteOut:
|
||||
d = dict(r)
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
return NoteOut(**d)
|
||||
|
||||
|
||||
@router.get("", response_model=list[NoteOut])
|
||||
async def list_notes(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
f"SELECT {_NOTE_COLS} FROM lore_notes ORDER BY id DESC"
|
||||
))).mappings().all()
|
||||
return [_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/subjects")
|
||||
async def list_subjects(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
|
||||
"""Not bağlanabilecek konular: kişiler (npcs) + yerler (map_tags)."""
|
||||
npcs = (await db.execute(text(
|
||||
"SELECT id, name, role FROM npcs ORDER BY name"
|
||||
))).mappings().all()
|
||||
places = (await db.execute(text(
|
||||
"SELECT id, name, category FROM map_tags ORDER BY name"
|
||||
))).mappings().all()
|
||||
return {
|
||||
"npc": [{"id": r["id"], "label": r["name"], "sub": r["role"] or ""} for r in npcs],
|
||||
"place": [{"id": r["id"], "label": r["name"], "sub": r["category"] or ""} for r in places],
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=NoteOut)
|
||||
async def add_note(n: NoteIn, user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
|
||||
row = (await db.execute(text(
|
||||
"INSERT INTO lore_notes(body,body_en,tag,subject_kind,subject_id,subject_label,author,author_id) "
|
||||
"VALUES(:body,:body_en,:tag,:sk,:sid,:slabel,:author,:aid) "
|
||||
f"RETURNING {_NOTE_COLS}"
|
||||
), {"body": n.body, "body_en": n.body_en, "tag": n.tag, "sk": n.subject_kind,
|
||||
"sid": n.subject_id, "slabel": n.subject_label,
|
||||
"author": _author(user), "aid": user.id})).mappings().one()
|
||||
await db.commit()
|
||||
return _row(row)
|
||||
|
||||
|
||||
# ---- Temel Hikaye — uzun metin bölümleri (lore_story) ----
|
||||
# ÖNEMLİ: /lore/story yolları /lore/{note_id}'den ÖNCE tanımlı olmalı (route sırası).
|
||||
|
||||
class StoryIn(BaseModel):
|
||||
title: str | None = Field(default=None, max_length=120)
|
||||
body: str = Field(min_length=1, max_length=200000)
|
||||
body_en: str | None = Field(default=None, max_length=200000)
|
||||
|
||||
|
||||
class StoryOut(BaseModel):
|
||||
id: int
|
||||
title: str | None
|
||||
body: str
|
||||
body_en: str | None
|
||||
author: str | None
|
||||
author_id: int | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _srow(r) -> StoryOut:
|
||||
d = dict(r)
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
return StoryOut(**d)
|
||||
|
||||
|
||||
@router.get("/story", response_model=list[StoryOut])
|
||||
async def list_story(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
"SELECT id,title,body,body_en,author,author_id,created_at,updated_at FROM lore_story ORDER BY sort_order,id"
|
||||
))).mappings().all()
|
||||
return [_srow(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/story", response_model=StoryOut)
|
||||
async def add_story(s: StoryIn, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
row = (await db.execute(text(
|
||||
"INSERT INTO lore_story(title,body,body_en,author,author_id) VALUES(:title,:body,:body_en,:author,:aid) "
|
||||
"RETURNING id,title,body,body_en,author,author_id,created_at,updated_at"
|
||||
), {"title": s.title, "body": s.body, "body_en": s.body_en, "author": _author(user), "aid": user.id})).mappings().one()
|
||||
await db.commit()
|
||||
return _srow(row)
|
||||
|
||||
|
||||
@router.post("/story/{story_id}", response_model=StoryOut)
|
||||
async def update_story(story_id: int, s: StoryIn, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "lore_story", story_id)
|
||||
row = (await db.execute(text(
|
||||
"UPDATE lore_story SET title=:title,body=:body,body_en=:body_en,updated_at=now() WHERE id=:id "
|
||||
"RETURNING id,title,body,body_en,author,author_id,created_at,updated_at"
|
||||
), {"id": story_id, "title": s.title, "body": s.body, "body_en": s.body_en})).mappings().one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Bölüm yok")
|
||||
await db.commit()
|
||||
return _srow(row)
|
||||
|
||||
|
||||
@router.post("/story/{story_id}/delete")
|
||||
async def delete_story(story_id: int, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "lore_story", story_id)
|
||||
res = await db.execute(text("DELETE FROM lore_story WHERE id=:id"), {"id": story_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Bölüm yok")
|
||||
return {"deleted": story_id}
|
||||
|
||||
|
||||
@router.post("/{note_id}", response_model=NoteOut)
|
||||
async def update_note(note_id: int, n: NoteIn, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "lore_notes", note_id)
|
||||
row = (await db.execute(text(
|
||||
"UPDATE lore_notes SET body=:body,body_en=:body_en,tag=:tag,"
|
||||
"subject_kind=:sk,subject_id=:sid,subject_label=:slabel,updated_at=now() WHERE id=:id "
|
||||
f"RETURNING {_NOTE_COLS}"
|
||||
), {"id": note_id, "body": n.body, "body_en": n.body_en, "tag": n.tag,
|
||||
"sk": n.subject_kind, "sid": n.subject_id, "slabel": n.subject_label})).mappings().one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Not yok")
|
||||
await db.commit()
|
||||
return _row(row)
|
||||
|
||||
|
||||
@router.post("/{note_id}/delete")
|
||||
async def delete_note(note_id: int, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, "lore_notes", note_id)
|
||||
res = await db.execute(text("DELETE FROM lore_notes WHERE id=:id"), {"id": note_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Not yok")
|
||||
return {"deleted": note_id}
|
||||
266
routers/map_tags.py
Normal file
266
routers/map_tags.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""
|
||||
Harita etiketleri — kıta/bölge/POI alanlarını harita üstünde işaretle.
|
||||
|
||||
points: [[x,y], ...] normalize (0-1) koordinatlar. poi = tek nokta, bölge = poligon.
|
||||
GET /map-tags → herkese açık liste (editör + oyun okur)
|
||||
POST /map-tags → "map_edit" izni (yeni etiket)
|
||||
PUT /map-tags/{id} → admin (güncelle)
|
||||
DELETE /map-tags/{id} → admin (sil)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import require_perm
|
||||
from models import User, get_db
|
||||
|
||||
router = APIRouter(prefix="/map-tags", tags=["map-tags"])
|
||||
|
||||
KINDS = {"continent", "region", "poi"}
|
||||
|
||||
# Kategori kataloğu (tip artık isim prefix'inde değil, yapısal). kind ile eşleşmeli.
|
||||
CATEGORIES: dict[str, dict] = {
|
||||
# region (zone) kategorileri
|
||||
"forest": {"kind": "region", "label": "Orman", "icon": "🌲", "color": "#2e7d32"},
|
||||
"city": {"kind": "region", "label": "Şehir", "icon": "🏙", "color": "#c9a227"},
|
||||
"mountain": {"kind": "region", "label": "Dağ", "icon": "⛰", "color": "#8d6e63"},
|
||||
"lake": {"kind": "region", "label": "Göl / Kıyı", "icon": "💧", "color": "#1e88e5"},
|
||||
"plains": {"kind": "region", "label": "Çayır", "icon": "🌾", "color": "#9ccc65"},
|
||||
"wasteland": {"kind": "region", "label": "Kavruk (magma)","icon": "🌋", "color": "#b71c1c"},
|
||||
"zone": {"kind": "region", "label": "Bölge (genel)", "icon": "▱", "color": "#78909c"},
|
||||
# poi (landmark) kategorileri
|
||||
"town": {"kind": "poi", "label": "Kasaba", "icon": "🏘", "color": "#c9a227"},
|
||||
"temple": {"kind": "poi", "label": "Tapınak", "icon": "⛩", "color": "#ab47bc"},
|
||||
"cave": {"kind": "poi", "label": "Mağara", "icon": "🕳", "color": "#546e7a"},
|
||||
"mine": {"kind": "poi", "label": "Maden", "icon": "⛏", "color": "#8d6e63"},
|
||||
"market": {"kind": "poi", "label": "Pazar", "icon": "🛒", "color": "#26a69a"},
|
||||
"dungeon": {"kind": "poi", "label": "Zindan", "icon": "🚪", "color": "#c62828"},
|
||||
"port": {"kind": "poi", "label": "Liman", "icon": "⚓", "color": "#1565c0"},
|
||||
}
|
||||
# NPC/quest otomasyonu için: satıcılar ve görev verenler nereye yerleşir
|
||||
VENDOR_CATEGORIES = {"town", "city", "market"}
|
||||
QUESTGIVER_CATEGORIES = {"town", "city", "temple", "market"}
|
||||
|
||||
|
||||
class TagIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
kind: str = "region"
|
||||
category: str | None = None
|
||||
continent: str | None = None
|
||||
points: list[list[float]] = Field(min_length=1)
|
||||
color: str | None = None
|
||||
note: str | None = None
|
||||
region_ids: list[int] = Field(default_factory=list) # POI: içinde olduğu bölge(ler)
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
category: str | None
|
||||
continent: str | None
|
||||
points: list[list[float]]
|
||||
color: str | None
|
||||
note: str | None
|
||||
region_ids: list[int]
|
||||
|
||||
|
||||
def _color(t: TagIn) -> str | None:
|
||||
"""Renk verilmemişse kategoriden türet."""
|
||||
if t.color:
|
||||
return t.color
|
||||
if t.category and t.category in CATEGORIES:
|
||||
return CATEGORIES[t.category]["color"]
|
||||
return None
|
||||
|
||||
|
||||
def _validate(t: TagIn) -> None:
|
||||
if t.kind not in KINDS:
|
||||
raise HTTPException(400, f"kind: {', '.join(sorted(KINDS))}")
|
||||
if t.category is not None:
|
||||
cat = CATEGORIES.get(t.category)
|
||||
if not cat:
|
||||
raise HTTPException(400, f"geçersiz category: {t.category}")
|
||||
if cat["kind"] != t.kind:
|
||||
raise HTTPException(400, f"'{t.category}' kategorisi {cat['kind']} içindir, {t.kind} değil")
|
||||
if t.kind == "poi" and len(t.points) != 1:
|
||||
raise HTTPException(400, "poi tek nokta olmalı")
|
||||
if t.kind != "poi" and len(t.points) < 3:
|
||||
raise HTTPException(400, "bölge en az 3 köşe olmalı")
|
||||
for p in t.points:
|
||||
if len(p) != 2 or not all(0 <= c <= 1 for c in p):
|
||||
raise HTTPException(400, "koordinatlar [x,y] ve 0-1 arası olmalı")
|
||||
|
||||
|
||||
_COLS = "id,name,kind,category,continent,points,color,note,region_ids"
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories():
|
||||
"""Kategori kataloğu (frontend ikon/renk/etiket + otomasyon kuralları)."""
|
||||
return {
|
||||
"categories": CATEGORIES,
|
||||
"vendor": sorted(VENDOR_CATEGORIES),
|
||||
"questgiver": sorted(QUESTGIVER_CATEGORIES),
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagOut])
|
||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
f"SELECT {_COLS} FROM map_tags ORDER BY id"
|
||||
))).mappings().all()
|
||||
return [TagOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=TagOut)
|
||||
async def create_tag(t: TagIn, user: User = Depends(require_perm("map_edit")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
_validate(t)
|
||||
import json
|
||||
row = (await db.execute(text(
|
||||
"INSERT INTO map_tags(name,kind,category,continent,points,color,note,region_ids) "
|
||||
"VALUES(:name,:kind,:category,:continent,CAST(:points AS JSONB),:color,:note,CAST(:region_ids AS JSONB)) "
|
||||
f"RETURNING {_COLS}"
|
||||
), {"name": t.name, "kind": t.kind, "category": t.category, "continent": t.continent,
|
||||
"points": json.dumps(t.points), "color": _color(t), "note": t.note,
|
||||
"region_ids": json.dumps(t.region_ids)})).mappings().one()
|
||||
await db.commit()
|
||||
return TagOut(**dict(row))
|
||||
|
||||
|
||||
# --- Lokasyonlar + yollar (editörün 3. işlemi: yol çizme) ---
|
||||
# Lokasyonlar 0-1000 grid'te (map_x/map_y); editör/oyun 0-1 normalize kullanır.
|
||||
# roads.waypoints = normalize (0-1) [[x,y],...] ara noktalar (uçlar hariç).
|
||||
# NOT: bu route'lar POST /{tag_id} catch-all'ından ÖNCE tanımlanmalı (aksi halde
|
||||
# "roads" tag_id:int olarak parse edilip 422 döner).
|
||||
|
||||
class LocationOut(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
type: str
|
||||
x: float # normalize 0-1
|
||||
y: float
|
||||
|
||||
|
||||
class RoadOut(BaseModel):
|
||||
id: int
|
||||
a: str # loc code
|
||||
b: str
|
||||
step_cost: int
|
||||
event_chance: float
|
||||
waypoints: list[list[float]]
|
||||
|
||||
|
||||
class RoadIn(BaseModel):
|
||||
a: str
|
||||
b: str
|
||||
step_cost: int = Field(ge=1, le=100000)
|
||||
event_chance: float = Field(default=0.3, ge=0.0, le=1.0)
|
||||
waypoints: list[list[float]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/locations", response_model=list[LocationOut])
|
||||
async def list_locations(db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
"SELECT id, code, name, type, map_x, map_y FROM locations ORDER BY name"
|
||||
))).mappings().all()
|
||||
return [LocationOut(id=r["id"], code=r["code"], name=r["name"], type=r["type"],
|
||||
x=r["map_x"] / 1000.0, y=r["map_y"] / 1000.0) for r in rows]
|
||||
|
||||
|
||||
@router.get("/roads", response_model=list[RoadOut])
|
||||
async def list_roads(db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
"SELECT r.id, a.code AS a, b.code AS b, r.step_cost, r.event_chance, r.waypoints "
|
||||
"FROM roads r JOIN locations a ON a.id=r.loc_a JOIN locations b ON b.id=r.loc_b "
|
||||
"ORDER BY r.id"
|
||||
))).mappings().all()
|
||||
return [RoadOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
def _validate_wp(wps: list[list[float]]) -> None:
|
||||
for p in wps:
|
||||
if len(p) != 2 or not all(0 <= c <= 1 for c in p):
|
||||
raise HTTPException(400, "waypoint [x,y] ve 0-1 arası olmalı")
|
||||
|
||||
|
||||
@router.post("/roads", response_model=RoadOut)
|
||||
async def upsert_road(r: RoadIn, user: User = Depends(require_perm("map_edit")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
import json
|
||||
if r.a == r.b:
|
||||
raise HTTPException(400, "Yol aynı lokasyona bağlanamaz")
|
||||
_validate_wp(r.waypoints)
|
||||
ids = (await db.execute(text(
|
||||
"SELECT code, id FROM locations WHERE code IN (:a, :b)"
|
||||
), {"a": r.a, "b": r.b})).all()
|
||||
id_by_code = {c: i for c, i in ids}
|
||||
if r.a not in id_by_code or r.b not in id_by_code:
|
||||
raise HTTPException(400, "Lokasyon bulunamadı")
|
||||
ia, ib = id_by_code[r.a], id_by_code[r.b]
|
||||
# roads UNIQUE(loc_a, loc_b) yönlü — her iki yönü de kontrol et, varsa güncelle
|
||||
existing = (await db.execute(text(
|
||||
"SELECT id, loc_a, loc_b FROM roads WHERE (loc_a=:a AND loc_b=:b) OR (loc_a=:b AND loc_b=:a)"
|
||||
), {"a": ia, "b": ib})).first()
|
||||
wp = r.waypoints
|
||||
if existing:
|
||||
# Kayıtlı yön a→b değilse waypoint sırasını ters çevir (uçlarla tutarlı kalsın)
|
||||
if existing[1] != ia:
|
||||
wp = list(reversed(wp))
|
||||
row = (await db.execute(text(
|
||||
"UPDATE roads SET step_cost=:sc, event_chance=:ec, waypoints=CAST(:wp AS JSONB) "
|
||||
"WHERE id=:id "
|
||||
"RETURNING id, (SELECT code FROM locations WHERE id=loc_a) AS a, "
|
||||
"(SELECT code FROM locations WHERE id=loc_b) AS b, step_cost, event_chance, waypoints"
|
||||
), {"id": existing[0], "sc": r.step_cost, "ec": r.event_chance, "wp": json.dumps(wp)})).mappings().one()
|
||||
else:
|
||||
row = (await db.execute(text(
|
||||
"INSERT INTO roads(loc_a, loc_b, step_cost, event_chance, waypoints) "
|
||||
"VALUES(:a, :b, :sc, :ec, CAST(:wp AS JSONB)) "
|
||||
"RETURNING id, (SELECT code FROM locations WHERE id=loc_a) AS a, "
|
||||
"(SELECT code FROM locations WHERE id=loc_b) AS b, step_cost, event_chance, waypoints"
|
||||
), {"a": ia, "b": ib, "sc": r.step_cost, "ec": r.event_chance, "wp": json.dumps(wp)})).mappings().one()
|
||||
await db.commit()
|
||||
return RoadOut(**dict(row))
|
||||
|
||||
|
||||
@router.post("/roads/{road_id}/delete")
|
||||
async def delete_road(road_id: int, user: User = Depends(require_perm("map_edit")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(text("DELETE FROM roads WHERE id=:id"), {"id": road_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Yol yok")
|
||||
return {"deleted": road_id}
|
||||
|
||||
|
||||
@router.post("/{tag_id}", response_model=TagOut)
|
||||
async def update_tag(tag_id: int, t: TagIn, user: User = Depends(require_perm("map_edit")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
_validate(t)
|
||||
import json
|
||||
row = (await db.execute(text(
|
||||
"UPDATE map_tags SET name=:name,kind=:kind,category=:category,continent=:continent,"
|
||||
"points=CAST(:points AS JSONB),color=:color,note=:note,"
|
||||
"region_ids=CAST(:region_ids AS JSONB),updated_at=now() "
|
||||
f"WHERE id=:id RETURNING {_COLS}"
|
||||
), {"id": tag_id, "name": t.name, "kind": t.kind, "category": t.category, "continent": t.continent,
|
||||
"points": json.dumps(t.points), "color": _color(t), "note": t.note,
|
||||
"region_ids": json.dumps(t.region_ids)})).mappings().one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Etiket yok")
|
||||
await db.commit()
|
||||
return TagOut(**dict(row))
|
||||
|
||||
|
||||
@router.post("/{tag_id}/delete")
|
||||
async def delete_tag(tag_id: int, user: User = Depends(require_perm("map_edit")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(text("DELETE FROM map_tags WHERE id=:id"), {"id": tag_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Etiket yok")
|
||||
return {"deleted": tag_id}
|
||||
505
routers/market.py
Normal file
505
routers/market.py
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
"""
|
||||
Pazar — oyuncu arası ticaret (gold ile) + NPC vendor.
|
||||
|
||||
Kurallar:
|
||||
- İlan verme / satın alma / vendor'a satma SADECE 'pazar' lokasyonundayken.
|
||||
- İlanları görüntüleme her yerden serbest (vitrin).
|
||||
- Para birimi: gold (characters.gold). Vendor her şeyi items.base_value'dan alır.
|
||||
- İlan iptali her yerden yapılabilir (item envantere döner).
|
||||
|
||||
Endpoint'ler:
|
||||
- GET /market/listings → açık ilanlar (q= arama, mine= sadece benimkiler)
|
||||
- POST /market/list → {item_code, qty, unit_price} ilan ver
|
||||
- POST /market/buy → {listing_id, qty?} satın al (kısmi alım destekli)
|
||||
- POST /market/cancel → {listing_id} ilanı iptal et
|
||||
- GET /market/vendor → envanterim + vendor'un ödeyeceği birim fiyatlar
|
||||
- POST /market/vendor/sell → {item_code, qty} vendor'a sat → gold
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import get_current_user
|
||||
from models import Character, User, get_db
|
||||
from routers.harvest import consume_items
|
||||
from routers.travel import _add_items
|
||||
|
||||
router = APIRouter(prefix="/market", tags=["market"])
|
||||
|
||||
MARKET_LOCATION = "pazar"
|
||||
MAX_OPEN_LISTINGS = 10
|
||||
|
||||
|
||||
async def _char(db: AsyncSession, user: User) -> Character:
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
return char
|
||||
|
||||
|
||||
async def _require_at_market(db: AsyncSession, char: Character) -> None:
|
||||
row = (await db.execute(text(
|
||||
"SELECT code FROM locations WHERE id = :i"
|
||||
), {"i": char.location_id})).first()
|
||||
if not row or row[0] != MARKET_LOCATION:
|
||||
raise HTTPException(400, "Bunun için Pazar'da olmalısın")
|
||||
|
||||
|
||||
# ---------- İlanlar ----------
|
||||
|
||||
class ListingOut(BaseModel):
|
||||
id: int
|
||||
item_code: str
|
||||
item_name: str
|
||||
rarity: str
|
||||
icon: Optional[str] = None
|
||||
qty: int
|
||||
unit_price: int
|
||||
seller_name: str
|
||||
mine: bool
|
||||
|
||||
|
||||
@router.get("/listings", response_model=list[ListingOut])
|
||||
async def listings(q: str = "", mine: bool = False,
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
sql = """
|
||||
SELECT ml.id, i.code, i.name, i.rarity, i.icon, ml.qty, ml.unit_price, c.name, ml.seller_id
|
||||
FROM market_listings ml
|
||||
JOIN items i ON i.id = ml.item_id
|
||||
JOIN characters c ON c.id = ml.seller_id
|
||||
WHERE ml.status = 'open'
|
||||
"""
|
||||
params: dict = {}
|
||||
if q:
|
||||
sql += " AND i.name ILIKE :q"
|
||||
params["q"] = f"%{q}%"
|
||||
if mine:
|
||||
sql += " AND ml.seller_id = :me"
|
||||
params["me"] = char.id
|
||||
sql += " ORDER BY i.name, ml.unit_price LIMIT 100"
|
||||
rows = (await db.execute(text(sql), params)).all()
|
||||
return [ListingOut(id=r[0], item_code=r[1], item_name=r[2], rarity=r[3], icon=r[4],
|
||||
qty=r[5], unit_price=r[6], seller_name=r[7], mine=(r[8] == char.id))
|
||||
for r in rows]
|
||||
|
||||
|
||||
class ListIn(BaseModel):
|
||||
item_code: str
|
||||
qty: int = Field(ge=1, le=999)
|
||||
unit_price: int = Field(ge=1, le=1_000_000)
|
||||
|
||||
|
||||
class ListingActionOut(BaseModel):
|
||||
listing_id: int
|
||||
gold: int
|
||||
|
||||
|
||||
@router.post("/list", response_model=ListingActionOut)
|
||||
async def create_listing(l: ListIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
await _require_at_market(db, char)
|
||||
|
||||
open_count = (await db.execute(text(
|
||||
"SELECT count(*) FROM market_listings WHERE seller_id=:s AND status='open'"
|
||||
), {"s": char.id})).scalar_one()
|
||||
if open_count >= MAX_OPEN_LISTINGS:
|
||||
raise HTTPException(400, f"En fazla {MAX_OPEN_LISTINGS} açık ilanın olabilir")
|
||||
|
||||
item = (await db.execute(text(
|
||||
"SELECT id, name FROM items WHERE code = :c"), {"c": l.item_code})).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item yok")
|
||||
|
||||
await consume_items(db, char.id, item[0], l.qty, item[1])
|
||||
lid = (await db.execute(text("""
|
||||
INSERT INTO market_listings (seller_id, item_id, qty, unit_price)
|
||||
VALUES (:s, :i, :q, :p) RETURNING id
|
||||
"""), {"s": char.id, "i": item[0], "q": l.qty, "p": l.unit_price})).scalar_one()
|
||||
await db.commit()
|
||||
return ListingActionOut(listing_id=lid, gold=char.gold)
|
||||
|
||||
|
||||
class BuyIn(BaseModel):
|
||||
listing_id: int
|
||||
qty: Optional[int] = Field(None, ge=1, le=999) # None = tamamı
|
||||
|
||||
|
||||
class BuyOut(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
qty: int
|
||||
total_price: int
|
||||
gold: int
|
||||
|
||||
|
||||
@router.post("/buy", response_model=BuyOut)
|
||||
async def buy(b: BuyIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
await _require_at_market(db, char)
|
||||
|
||||
row = (await db.execute(text("""
|
||||
SELECT ml.id, ml.seller_id, ml.item_id, ml.qty, ml.unit_price, i.code, i.name
|
||||
FROM market_listings ml JOIN items i ON i.id = ml.item_id
|
||||
WHERE ml.id = :id AND ml.status = 'open' FOR UPDATE OF ml
|
||||
"""), {"id": b.listing_id})).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "İlan yok ya da kapanmış")
|
||||
if row[1] == char.id:
|
||||
raise HTTPException(400, "Kendi ilanını satın alamazsın")
|
||||
|
||||
qty = b.qty if b.qty is not None else int(row[3])
|
||||
if qty > int(row[3]):
|
||||
raise HTTPException(400, f"İlanda sadece {row[3]} adet var")
|
||||
total = qty * int(row[4])
|
||||
if char.gold < total:
|
||||
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
|
||||
|
||||
char.gold -= total
|
||||
await db.execute(text("UPDATE characters SET gold = gold + :g WHERE id = :s"),
|
||||
{"g": total, "s": row[1]})
|
||||
await _add_items(db, char.id, row[5], qty)
|
||||
|
||||
if qty == int(row[3]):
|
||||
await db.execute(text(
|
||||
"UPDATE market_listings SET status='sold', closed_at=:t WHERE id=:id"
|
||||
), {"t": datetime.now(timezone.utc), "id": row[0]})
|
||||
else:
|
||||
await db.execute(text("UPDATE market_listings SET qty = qty - :q WHERE id=:id"),
|
||||
{"q": qty, "id": row[0]})
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO trade_logs (seller_id, buyer_id, item_id, qty, unit_price)
|
||||
VALUES (:s, :b, :i, :q, :p)
|
||||
"""), {"s": row[1], "b": char.id, "i": row[2], "q": qty, "p": row[4]})
|
||||
await db.commit()
|
||||
return BuyOut(item_code=row[5], item_name=row[6], qty=qty, total_price=total, gold=char.gold)
|
||||
|
||||
|
||||
class CancelIn(BaseModel):
|
||||
listing_id: int
|
||||
|
||||
|
||||
@router.post("/cancel", response_model=ListingActionOut)
|
||||
async def cancel(cn: CancelIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
row = (await db.execute(text("""
|
||||
SELECT ml.id, ml.item_id, ml.qty, i.code
|
||||
FROM market_listings ml JOIN items i ON i.id = ml.item_id
|
||||
WHERE ml.id = :id AND ml.status = 'open' AND ml.seller_id = :s FOR UPDATE OF ml
|
||||
"""), {"id": cn.listing_id, "s": char.id})).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "Açık ilanın yok")
|
||||
await db.execute(text(
|
||||
"UPDATE market_listings SET status='cancelled', closed_at=:t WHERE id=:id"
|
||||
), {"t": datetime.now(timezone.utc), "id": row[0]})
|
||||
await _add_items(db, char.id, row[3], int(row[2]))
|
||||
await db.commit()
|
||||
return ListingActionOut(listing_id=row[0], gold=char.gold)
|
||||
|
||||
|
||||
# ---------- NPC Vendor ----------
|
||||
|
||||
class VendorRow(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
rarity: str
|
||||
qty: int
|
||||
unit_price: int # vendor'un ödeyeceği gold
|
||||
|
||||
|
||||
@router.get("/vendor", response_model=list[VendorRow])
|
||||
async def vendor_prices(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
rows = (await db.execute(text("""
|
||||
SELECT i.code, i.name, i.rarity, SUM(inv.qty), i.base_value
|
||||
FROM inventory inv JOIN items i ON i.id = inv.item_id
|
||||
WHERE inv.character_id = :cid
|
||||
GROUP BY i.code, i.name, i.rarity, i.base_value
|
||||
ORDER BY i.name
|
||||
"""), {"cid": char.id})).all()
|
||||
return [VendorRow(item_code=r[0], item_name=r[1], rarity=r[2], qty=int(r[3]), unit_price=int(r[4]))
|
||||
for r in rows]
|
||||
|
||||
|
||||
class VendorSellIn(BaseModel):
|
||||
item_code: str
|
||||
qty: int = Field(ge=1, le=999)
|
||||
|
||||
|
||||
class VendorSellOut(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
qty: int
|
||||
gold_gained: int
|
||||
gold: int
|
||||
|
||||
|
||||
@router.post("/vendor/sell", response_model=VendorSellOut)
|
||||
async def vendor_sell(v: VendorSellIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
await _require_at_market(db, char)
|
||||
|
||||
item = (await db.execute(text(
|
||||
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": v.item_code})).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item yok")
|
||||
|
||||
await consume_items(db, char.id, item[0], v.qty, item[1])
|
||||
gained = int(item[2]) * v.qty
|
||||
char.gold += gained
|
||||
await db.commit()
|
||||
return VendorSellOut(item_code=v.item_code, item_name=item[1], qty=v.qty,
|
||||
gold_gained=gained, gold=char.gold)
|
||||
|
||||
|
||||
# ---------- Vendor stok (satın alma — gold sink) ----------
|
||||
|
||||
# Vendor'un sattığı temel mallar; fiyat = base_value × çarpan
|
||||
VENDOR_STOCK_MARKUP = 4
|
||||
VENDOR_STOCK_CODES = ["axe_stone", "pickaxe_stone", "fishing_rod", "tea_mint", "stone_small", "stick", "vine"]
|
||||
|
||||
|
||||
class VendorStockRow(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
rarity: str
|
||||
unit_price: int # gold
|
||||
|
||||
|
||||
@router.get("/vendor/stock", response_model=list[VendorStockRow])
|
||||
async def vendor_stock(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
await _char(db, user)
|
||||
from sqlalchemy import bindparam
|
||||
stmt = text(
|
||||
"SELECT code, name, rarity, base_value FROM items WHERE code IN :codes ORDER BY base_value"
|
||||
).bindparams(bindparam("codes", expanding=True))
|
||||
rows = (await db.execute(stmt, {"codes": VENDOR_STOCK_CODES})).all()
|
||||
return [VendorStockRow(item_code=r[0], item_name=r[1], rarity=r[2],
|
||||
unit_price=int(r[3]) * VENDOR_STOCK_MARKUP) for r in rows]
|
||||
|
||||
|
||||
class VendorBuyIn(BaseModel):
|
||||
item_code: str
|
||||
qty: int = Field(ge=1, le=10)
|
||||
|
||||
|
||||
class VendorBuyOut(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
qty: int
|
||||
total_price: int
|
||||
gold: int
|
||||
|
||||
|
||||
@router.post("/vendor/buy", response_model=VendorBuyOut)
|
||||
async def vendor_buy(v: VendorBuyIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
await _require_at_market(db, char)
|
||||
if v.item_code not in VENDOR_STOCK_CODES:
|
||||
raise HTTPException(400, "Vendor bunu satmıyor")
|
||||
|
||||
item = (await db.execute(text(
|
||||
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": v.item_code})).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item yok")
|
||||
|
||||
total = int(item[2]) * VENDOR_STOCK_MARKUP * v.qty
|
||||
if char.gold < total:
|
||||
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
|
||||
|
||||
char.gold -= total
|
||||
await _add_items(db, char.id, v.item_code, v.qty)
|
||||
await db.commit()
|
||||
return VendorBuyOut(item_code=v.item_code, item_name=item[1], qty=v.qty,
|
||||
total_price=total, gold=char.gold)
|
||||
|
||||
|
||||
# ---------- Şehir haritası: konum bazlı NPC vendor'lar ----------
|
||||
# Her şehir location'ının kendi vendor NPC'leri var (npcs.location_id).
|
||||
# Vendor stoğu = sabit stok (npc_items, sonsuz, base_value×markup)
|
||||
# + konsinye (vendor_stock: oyuncuların sattığı, 24s ömürlü, kendi fiyatı).
|
||||
# Oyuncu vendor'a sattığında: gold alır + ürün vendor'ın normal satmadığı bir şeyse
|
||||
# 24s ömürle konsinyeye eklenir (varsa süresi yenilenir). Normal sattığı üründe
|
||||
# sadece gold ödenir (sabit stok zaten sonsuz).
|
||||
|
||||
VENDOR_CONSIGN_HOURS = 24
|
||||
|
||||
|
||||
async def _clean_expired(db: AsyncSession) -> None:
|
||||
await db.execute(text("DELETE FROM vendor_stock WHERE expires_at < now()"))
|
||||
|
||||
|
||||
async def _vendor_at_here(db: AsyncSession, npc_id: int, char: Character) -> dict:
|
||||
"""npc_id gerçekten char'ın bulunduğu şehrin vendor'ı mı? değilse 400."""
|
||||
row = (await db.execute(text(
|
||||
"SELECT id, name, role, location_id FROM npcs WHERE id = :i"
|
||||
), {"i": npc_id})).first()
|
||||
if not row:
|
||||
raise HTTPException(404, "Satıcı yok")
|
||||
if row[3] != char.location_id:
|
||||
raise HTTPException(400, "Bu satıcı burada değil")
|
||||
return {"id": row[0], "name": row[1], "role": row[2]}
|
||||
|
||||
|
||||
class CityVendor(BaseModel):
|
||||
npc_id: int
|
||||
name: str
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
class CityOut(BaseModel):
|
||||
location_code: str
|
||||
location_name: str
|
||||
vendors: list[CityVendor]
|
||||
|
||||
|
||||
@router.get("/city", response_model=CityOut)
|
||||
async def city_vendors(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Bulunduğun şehrin vendor NPC'leri (şehir haritası pinleri)."""
|
||||
char = await _char(db, user)
|
||||
loc = (await db.execute(text(
|
||||
"SELECT code, name FROM locations WHERE id = :i"
|
||||
), {"i": char.location_id})).first()
|
||||
if not loc:
|
||||
raise HTTPException(404, "Konum yok")
|
||||
rows = (await db.execute(text(
|
||||
"SELECT id, name, role FROM npcs WHERE location_id = :l ORDER BY id"
|
||||
), {"l": char.location_id})).all()
|
||||
return CityOut(location_code=loc[0], location_name=loc[1],
|
||||
vendors=[CityVendor(npc_id=r[0], name=r[1], role=r[2]) for r in rows])
|
||||
|
||||
|
||||
class ShopRow(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
rarity: str
|
||||
icon: Optional[str] = None
|
||||
unit_price: int
|
||||
qty: Optional[int] = None # None = sabit stok (sonsuz); sayı = konsinye kalan
|
||||
source: str # "base" | "player"
|
||||
expires_in_h: Optional[int] = None
|
||||
|
||||
|
||||
class ShopOut(BaseModel):
|
||||
npc_id: int
|
||||
npc_name: str
|
||||
role: Optional[str] = None
|
||||
items: list[ShopRow]
|
||||
|
||||
|
||||
@router.get("/vendor/{npc_id}/shop", response_model=ShopOut)
|
||||
async def vendor_shop(npc_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
v = await _vendor_at_here(db, npc_id, char)
|
||||
await _clean_expired(db)
|
||||
# sabit stok — npc_items
|
||||
base = (await db.execute(text(
|
||||
"SELECT i.code, i.name, i.rarity, i.icon, i.base_value "
|
||||
"FROM npc_items ni JOIN items i ON i.id = ni.item_id "
|
||||
"WHERE ni.npc_id = :n ORDER BY i.base_value"
|
||||
), {"n": npc_id})).all()
|
||||
items = [ShopRow(item_code=r[0], item_name=r[1], rarity=r[2], icon=r[3],
|
||||
unit_price=int(r[4]) * VENDOR_STOCK_MARKUP, qty=None, source="base")
|
||||
for r in base]
|
||||
# konsinye — vendor_stock (aktif)
|
||||
cons = (await db.execute(text(
|
||||
"SELECT i.code, i.name, i.rarity, i.icon, vs.unit_price, vs.qty, "
|
||||
"CEIL(EXTRACT(EPOCH FROM (vs.expires_at - now()))/3600.0) "
|
||||
"FROM vendor_stock vs JOIN items i ON i.id = vs.item_id "
|
||||
"WHERE vs.npc_id = :n AND vs.expires_at > now() ORDER BY vs.expires_at"
|
||||
), {"n": npc_id})).all()
|
||||
items += [ShopRow(item_code=r[0], item_name=r[1], rarity=r[2], icon=r[3],
|
||||
unit_price=int(r[4]), qty=int(r[5]), source="player", expires_in_h=int(r[6]))
|
||||
for r in cons]
|
||||
await db.commit()
|
||||
return ShopOut(npc_id=v["id"], npc_name=v["name"], role=v["role"], items=items)
|
||||
|
||||
|
||||
class NpcBuyIn(BaseModel):
|
||||
item_code: str
|
||||
qty: int = Field(ge=1, le=99)
|
||||
|
||||
|
||||
@router.post("/vendor/{npc_id}/buy", response_model=VendorBuyOut)
|
||||
async def npc_buy(npc_id: int, b: NpcBuyIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
await _vendor_at_here(db, npc_id, char)
|
||||
await _clean_expired(db)
|
||||
item = (await db.execute(text(
|
||||
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": b.item_code})).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item yok")
|
||||
# önce konsinye stoğu (sınırlı), yoksa sabit stok (npc_items, sonsuz)
|
||||
cons = (await db.execute(text(
|
||||
"SELECT qty, unit_price FROM vendor_stock "
|
||||
"WHERE npc_id = :n AND item_id = :it AND expires_at > now()"
|
||||
), {"n": npc_id, "it": item[0]})).first()
|
||||
if cons is not None:
|
||||
if int(cons[0]) < b.qty:
|
||||
raise HTTPException(400, f"Stokta {int(cons[0])} adet var")
|
||||
unit = int(cons[1])
|
||||
total = unit * b.qty
|
||||
if char.gold < total:
|
||||
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
|
||||
remaining = int(cons[0]) - b.qty
|
||||
if remaining > 0:
|
||||
await db.execute(text("UPDATE vendor_stock SET qty = :q WHERE npc_id = :n AND item_id = :it"),
|
||||
{"q": remaining, "n": npc_id, "it": item[0]})
|
||||
else:
|
||||
await db.execute(text("DELETE FROM vendor_stock WHERE npc_id = :n AND item_id = :it"),
|
||||
{"n": npc_id, "it": item[0]})
|
||||
else:
|
||||
# sabit stok mu?
|
||||
is_base = (await db.execute(text(
|
||||
"SELECT 1 FROM npc_items WHERE npc_id = :n AND item_id = :it"
|
||||
), {"n": npc_id, "it": item[0]})).first()
|
||||
if not is_base:
|
||||
raise HTTPException(400, "Bu satıcı bunu satmıyor")
|
||||
unit = int(item[2]) * VENDOR_STOCK_MARKUP
|
||||
total = unit * b.qty
|
||||
if char.gold < total:
|
||||
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
|
||||
char.gold -= total
|
||||
await _add_items(db, char.id, b.item_code, b.qty)
|
||||
await db.commit()
|
||||
return VendorBuyOut(item_code=b.item_code, item_name=item[1], qty=b.qty,
|
||||
total_price=total, gold=char.gold)
|
||||
|
||||
|
||||
class NpcSellIn(BaseModel):
|
||||
item_code: str
|
||||
qty: int = Field(ge=1, le=999)
|
||||
|
||||
|
||||
@router.post("/vendor/{npc_id}/sell", response_model=VendorSellOut)
|
||||
async def npc_sell(npc_id: int, s: NpcSellIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
await _vendor_at_here(db, npc_id, char)
|
||||
item = (await db.execute(text(
|
||||
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": s.item_code})).first()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item yok")
|
||||
await consume_items(db, char.id, item[0], s.qty, item[1])
|
||||
gained = int(item[2]) * s.qty
|
||||
char.gold += gained
|
||||
# Vendor'ın normal satmadığı ürünse konsinyeye ekle (24s, süresi yenilenir).
|
||||
is_base = (await db.execute(text(
|
||||
"SELECT 1 FROM npc_items WHERE npc_id = :n AND item_id = :it"
|
||||
), {"n": npc_id, "it": item[0]})).first()
|
||||
if not is_base:
|
||||
unit = int(item[2]) * VENDOR_STOCK_MARKUP
|
||||
await db.execute(text(
|
||||
"INSERT INTO vendor_stock(npc_id, item_id, qty, unit_price, expires_at) "
|
||||
"VALUES (:n, :it, :q, :u, now() + make_interval(hours => :h)) "
|
||||
"ON CONFLICT (npc_id, item_id) DO UPDATE SET "
|
||||
"qty = vendor_stock.qty + EXCLUDED.qty, unit_price = EXCLUDED.unit_price, "
|
||||
"expires_at = now() + make_interval(hours => :h)"
|
||||
), {"n": npc_id, "it": item[0], "q": s.qty, "u": unit, "h": VENDOR_CONSIGN_HOURS})
|
||||
await db.commit()
|
||||
return VendorSellOut(item_code=s.item_code, item_name=item[1], qty=s.qty,
|
||||
gold_gained=gained, gold=char.gold)
|
||||
271
routers/npcs.py
Normal file
271
routers/npcs.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""
|
||||
NPC yönetimi — oyun için oluşturulan NPC listesi ("npc" izni).
|
||||
Konum = map editöründe çizilen alan (region) veya pin (poi) → map_tags.
|
||||
Taraf (stance): friendly (dost) / hostile (düşman) / neutral (tarafsız).
|
||||
Bağlar: quest tek vericiye bağlı (exclusive); item çoktan-çoğa (npc_items).
|
||||
WAF nedeniyle sadece GET/POST.
|
||||
GET /npcs → liste (yer + taraf + bağlı quest/item)
|
||||
GET /npcs/locations → yer dropdown verisi
|
||||
GET /npcs/catalog → quest + item kataloğu (çoktan seçmeli için)
|
||||
POST /npcs → yeni NPC
|
||||
POST /npcs/{id} → güncelle
|
||||
POST /npcs/{id}/delete → sil
|
||||
POST /npcs/{id}/quests → bu NPC'nin verdiği görevleri ayarla (exclusive)
|
||||
POST /npcs/{id}/items → bu NPC'nin sattığı itemları ayarla (replace)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import require_perm
|
||||
from models import User, get_db
|
||||
|
||||
router = APIRouter(prefix="/npcs", tags=["npcs"])
|
||||
|
||||
STANCES = {"friendly", "hostile", "neutral"}
|
||||
|
||||
|
||||
class QuestMini(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
|
||||
|
||||
class ItemMini(BaseModel):
|
||||
id: int
|
||||
code: str | None
|
||||
name: str
|
||||
type: str | None
|
||||
rarity: str | None
|
||||
|
||||
|
||||
class NpcIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
role: str | None = Field(default=None, max_length=80)
|
||||
map_tag_id: int | None = None
|
||||
stance: str = "neutral"
|
||||
description: str | None = Field(default=None, max_length=20000)
|
||||
notes: str | None = Field(default=None, max_length=20000)
|
||||
|
||||
|
||||
class NpcOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
role: str | None
|
||||
map_tag_id: int | None
|
||||
location_name: str | None
|
||||
location_kind: str | None
|
||||
location_category: str | None
|
||||
stance: str
|
||||
description: str | None
|
||||
notes: str | None
|
||||
quests: list[QuestMini]
|
||||
sells: list[ItemMini]
|
||||
updated_at: str
|
||||
|
||||
|
||||
class LocationRow(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
category: str | None
|
||||
continent: str | None
|
||||
|
||||
|
||||
class Catalog(BaseModel):
|
||||
quests: list[dict] # {id,title,giver_npc_id}
|
||||
items: list[ItemMini]
|
||||
|
||||
|
||||
class IdList(BaseModel):
|
||||
ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
async def _check_location(db: AsyncSession, map_tag_id: int | None) -> None:
|
||||
if map_tag_id is None:
|
||||
return
|
||||
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
|
||||
{"id": map_tag_id})).scalar()
|
||||
if not ok:
|
||||
raise HTTPException(400, "Yer yok (map editöründe çizilen alan/pin olmalı)")
|
||||
|
||||
|
||||
def _check_stance(stance: str) -> None:
|
||||
if stance not in STANCES:
|
||||
raise HTTPException(400, "Geçersiz taraf (friendly/hostile/neutral)")
|
||||
|
||||
|
||||
_SEL = (
|
||||
"SELECT n.id,n.name,n.role,n.map_tag_id,t.name AS location_name,t.kind AS location_kind,"
|
||||
"t.category AS location_category,n.stance,n.description,n.notes,n.updated_at "
|
||||
"FROM npcs n LEFT JOIN map_tags t ON t.id=n.map_tag_id "
|
||||
)
|
||||
|
||||
|
||||
async def _links(db: AsyncSession, npc_ids: list[int]) -> tuple[dict, dict]:
|
||||
"""Verilen npc'ler için {npc_id: [quests]} ve {npc_id: [items]} döndür."""
|
||||
quests: dict[int, list] = {i: [] for i in npc_ids}
|
||||
items: dict[int, list] = {i: [] for i in npc_ids}
|
||||
if not npc_ids:
|
||||
return quests, items
|
||||
qrows = (await db.execute(text(
|
||||
"SELECT id,title,giver_npc_id FROM quests WHERE giver_npc_id = ANY(:ids)"
|
||||
), {"ids": npc_ids})).mappings().all()
|
||||
for r in qrows:
|
||||
quests[r["giver_npc_id"]].append(QuestMini(id=r["id"], title=r["title"]))
|
||||
irows = (await db.execute(text(
|
||||
"SELECT ni.npc_id, i.id,i.code,i.name,i.type,i.rarity "
|
||||
"FROM npc_items ni JOIN items i ON i.id=ni.item_id "
|
||||
"WHERE ni.npc_id = ANY(:ids) ORDER BY i.type,i.name"
|
||||
), {"ids": npc_ids})).mappings().all()
|
||||
for r in irows:
|
||||
items[r["npc_id"]].append(ItemMini(id=r["id"], code=r["code"], name=r["name"],
|
||||
type=r["type"], rarity=r["rarity"]))
|
||||
return quests, items
|
||||
|
||||
|
||||
async def _one(db: AsyncSession, npc_id: int) -> NpcOut:
|
||||
got = (await db.execute(text(_SEL + "WHERE n.id=:id"), {"id": npc_id})).mappings().one()
|
||||
q, it = await _links(db, [npc_id])
|
||||
d = dict(got)
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
d["quests"] = q[npc_id]
|
||||
d["sells"] = it[npc_id]
|
||||
return NpcOut(**d)
|
||||
|
||||
|
||||
@router.get("", response_model=list[NpcOut])
|
||||
async def list_npcs(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(_SEL + "ORDER BY t.name NULLS LAST, n.name"))).mappings().all()
|
||||
ids = [r["id"] for r in rows]
|
||||
q, it = await _links(db, ids)
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
d["quests"] = q[r["id"]]
|
||||
d["sells"] = it[r["id"]]
|
||||
out.append(NpcOut(**d))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/locations", response_model=list[LocationRow])
|
||||
async def npc_locations(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(text(
|
||||
"SELECT id,name,kind,category,continent FROM map_tags WHERE kind IN ('region','poi') "
|
||||
"ORDER BY kind DESC, continent NULLS LAST, name"
|
||||
))).mappings().all()
|
||||
return [LocationRow(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=Catalog)
|
||||
async def catalog(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)):
|
||||
qrows = (await db.execute(text(
|
||||
"SELECT id,title,giver_npc_id FROM quests ORDER BY title"
|
||||
))).mappings().all()
|
||||
irows = (await db.execute(text(
|
||||
"SELECT id,code,name,type,rarity FROM items ORDER BY type,name"
|
||||
))).mappings().all()
|
||||
return Catalog(
|
||||
quests=[dict(r) for r in qrows],
|
||||
items=[ItemMini(**dict(r)) for r in irows],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=NpcOut)
|
||||
async def create_npc(n: NpcIn, user: User = Depends(require_perm("npc")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _check_location(db, n.map_tag_id)
|
||||
_check_stance(n.stance)
|
||||
row = (await db.execute(text(
|
||||
"INSERT INTO npcs(name,role,map_tag_id,stance,description,notes) "
|
||||
"VALUES(:name,:role,:tag,:stance,:descr,:notes) RETURNING id"
|
||||
), {"name": n.name, "role": n.role, "tag": n.map_tag_id, "stance": n.stance,
|
||||
"descr": n.description, "notes": n.notes})).scalar_one()
|
||||
await db.commit()
|
||||
return await _one(db, row)
|
||||
|
||||
|
||||
@router.post("/{npc_id}", response_model=NpcOut)
|
||||
async def update_npc(npc_id: int, n: NpcIn, user: User = Depends(require_perm("npc")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _check_location(db, n.map_tag_id)
|
||||
_check_stance(n.stance)
|
||||
res = await db.execute(text(
|
||||
"UPDATE npcs SET name=:name,role=:role,map_tag_id=:tag,stance=:stance,"
|
||||
"description=:descr,notes=:notes,updated_at=now() WHERE id=:id"
|
||||
), {"id": npc_id, "name": n.name, "role": n.role, "tag": n.map_tag_id, "stance": n.stance,
|
||||
"descr": n.description, "notes": n.notes})
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "NPC yok")
|
||||
await db.commit()
|
||||
return await _one(db, npc_id)
|
||||
|
||||
|
||||
@router.post("/{npc_id}/quests", response_model=NpcOut)
|
||||
async def set_quests(npc_id: int, body: IdList, user: User = Depends(require_perm("npc")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
"""Bu NPC'nin verdiği görevleri ayarla. Görev tek vericiye bağlıdır (exclusive)."""
|
||||
exists = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": npc_id})).scalar()
|
||||
if not exists:
|
||||
raise HTTPException(404, "NPC yok")
|
||||
ids = list(dict.fromkeys(body.ids))
|
||||
if ids:
|
||||
# başka NPC'ye bağlı görev çalınamaz
|
||||
taken = (await db.execute(text(
|
||||
"SELECT title FROM quests WHERE id = ANY(:ids) AND giver_npc_id IS NOT NULL "
|
||||
"AND giver_npc_id <> :nid"
|
||||
), {"ids": ids, "nid": npc_id})).scalars().all()
|
||||
if taken:
|
||||
raise HTTPException(409, f"Bu görev(ler) başka NPC'ye bağlı: {', '.join(taken)}")
|
||||
bad = (await db.execute(text(
|
||||
"SELECT :cnt - count(*) FROM quests WHERE id = ANY(:ids)"
|
||||
), {"ids": ids, "cnt": len(ids)})).scalar()
|
||||
if bad:
|
||||
raise HTTPException(400, "Geçersiz görev id")
|
||||
# bu npc'nin listeden çıkarılan görevlerini serbest bırak
|
||||
await db.execute(text(
|
||||
"UPDATE quests SET giver_npc_id=NULL WHERE giver_npc_id=:nid "
|
||||
"AND NOT (id = ANY(:ids))"
|
||||
), {"nid": npc_id, "ids": ids})
|
||||
# seçilenleri bu npc'ye bağla
|
||||
if ids:
|
||||
await db.execute(text(
|
||||
"UPDATE quests SET giver_npc_id=:nid WHERE id = ANY(:ids)"
|
||||
), {"nid": npc_id, "ids": ids})
|
||||
await db.commit()
|
||||
return await _one(db, npc_id)
|
||||
|
||||
|
||||
@router.post("/{npc_id}/items", response_model=NpcOut)
|
||||
async def set_items(npc_id: int, body: IdList, user: User = Depends(require_perm("npc")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
"""Bu NPC'nin sattığı itemları ayarla (replace). Bir item birden çok NPC'de olabilir."""
|
||||
exists = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": npc_id})).scalar()
|
||||
if not exists:
|
||||
raise HTTPException(404, "NPC yok")
|
||||
ids = list(dict.fromkeys(body.ids))
|
||||
if ids:
|
||||
bad = (await db.execute(text(
|
||||
"SELECT :cnt - count(*) FROM items WHERE id = ANY(:ids)"
|
||||
), {"ids": ids, "cnt": len(ids)})).scalar()
|
||||
if bad:
|
||||
raise HTTPException(400, "Geçersiz item id")
|
||||
await db.execute(text("DELETE FROM npc_items WHERE npc_id=:nid"), {"nid": npc_id})
|
||||
if ids:
|
||||
await db.execute(text(
|
||||
"INSERT INTO npc_items(npc_id,item_id) "
|
||||
"SELECT :nid, x FROM unnest(cast(:ids AS bigint[])) AS x ON CONFLICT DO NOTHING"
|
||||
), {"nid": npc_id, "ids": ids})
|
||||
await db.commit()
|
||||
return await _one(db, npc_id)
|
||||
|
||||
|
||||
@router.post("/{npc_id}/delete")
|
||||
async def delete_npc(npc_id: int, user: User = Depends(require_perm("npc")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
res = await db.execute(text("DELETE FROM npcs WHERE id=:id"), {"id": npc_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "NPC yok")
|
||||
return {"deleted": npc_id}
|
||||
258
routers/quests.py
Normal file
258
routers/quests.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""
|
||||
Görevler — ana + yan görevler ("lore" izni; loremaster + admin).
|
||||
Her görevin: görevi veren NPC, kıta, bölge (map_tags region), veriliş noktası (location/pin)
|
||||
ve "nerede yapılacağı" (çok seçmeli objective location'lar) bilgisi tutulur.
|
||||
Sahiplik: admin herkesinkini, diğerleri sadece kendi görevini düzenler/siler.
|
||||
WAF nedeniyle sadece GET/POST.
|
||||
GET /quests → liste (giver + konum + bölge adları + objective'ler)
|
||||
GET /quests/meta → dropdown verisi (npcs, locations, regions, continents)
|
||||
POST /quests → yeni görev
|
||||
POST /quests/{id} → güncelle
|
||||
POST /quests/{id}/delete → sil
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import require_perm, get_user_perms, has_perm
|
||||
from models import User, get_db
|
||||
|
||||
router = APIRouter(prefix="/quests", tags=["quests"])
|
||||
|
||||
TYPES = {"main", "side"}
|
||||
|
||||
|
||||
def _author(user: User) -> str:
|
||||
return (user.display_name or "").strip() or user.email
|
||||
|
||||
|
||||
async def _assert_owner_or_admin(db: AsyncSession, user: User, row_id: int):
|
||||
perms = await get_user_perms(user, db)
|
||||
if has_perm(perms, "*"):
|
||||
return
|
||||
owner = (await db.execute(text(
|
||||
"SELECT author_id FROM quests WHERE id=:id"
|
||||
), {"id": row_id})).scalar_one_or_none()
|
||||
if owner is None:
|
||||
raise HTTPException(404, "Görev yok")
|
||||
if owner != user.id:
|
||||
raise HTTPException(403, "Sadece kendi görevini düzenleyebilir/silebilirsin")
|
||||
|
||||
|
||||
# ---- Şemalar ----
|
||||
|
||||
class ObjectiveIn(BaseModel):
|
||||
map_tag_id: int | None = None
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class QuestIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
quest_type: str = "side"
|
||||
summary: str | None = Field(default=None, max_length=20000)
|
||||
giver_npc_id: int | None = None
|
||||
continent: str | None = Field(default=None, max_length=80)
|
||||
region_tag_id: int | None = None
|
||||
location_tag_id: int | None = None
|
||||
objectives: list[ObjectiveIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ObjectiveOut(BaseModel):
|
||||
id: int
|
||||
map_tag_id: int | None
|
||||
location_name: str | None
|
||||
location_kind: str | None
|
||||
note: str | None
|
||||
|
||||
|
||||
class QuestOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
quest_type: str
|
||||
summary: str | None
|
||||
giver_npc_id: int | None
|
||||
giver_npc_name: str | None
|
||||
continent: str | None
|
||||
region_tag_id: int | None
|
||||
region_name: str | None
|
||||
location_tag_id: int | None
|
||||
location_name: str | None
|
||||
author: str | None
|
||||
author_id: int | None
|
||||
objectives: list[ObjectiveOut]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
# ---- Meta (dropdown) ----
|
||||
|
||||
class MetaNpc(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
map_tag_id: int | None
|
||||
location_name: str | None
|
||||
|
||||
|
||||
class MetaPlace(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
continent: str | None
|
||||
|
||||
|
||||
class MetaRegion(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
continent: str | None
|
||||
|
||||
|
||||
class MetaOut(BaseModel):
|
||||
npcs: list[MetaNpc]
|
||||
places: list[MetaPlace]
|
||||
regions: list[MetaRegion]
|
||||
continents: list[str]
|
||||
|
||||
|
||||
@router.get("/meta", response_model=MetaOut)
|
||||
async def quest_meta(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
|
||||
npcs = (await db.execute(text(
|
||||
"SELECT n.id,n.name,n.map_tag_id,t.name AS location_name "
|
||||
"FROM npcs n LEFT JOIN map_tags t ON t.id=n.map_tag_id ORDER BY n.name"
|
||||
))).mappings().all()
|
||||
places = (await db.execute(text(
|
||||
"SELECT id,name,kind,continent FROM map_tags WHERE kind IN ('region','poi') "
|
||||
"ORDER BY kind DESC, continent NULLS LAST, name"
|
||||
))).mappings().all()
|
||||
regions = (await db.execute(text(
|
||||
"SELECT id,name,continent FROM map_tags WHERE kind='region' ORDER BY continent NULLS LAST,name"
|
||||
))).mappings().all()
|
||||
conts = (await db.execute(text(
|
||||
"SELECT name FROM map_tags WHERE kind='continent' ORDER BY name"
|
||||
))).scalars().all()
|
||||
return MetaOut(
|
||||
npcs=[MetaNpc(**dict(r)) for r in npcs],
|
||||
places=[MetaPlace(**dict(r)) for r in places],
|
||||
regions=[MetaRegion(**dict(r)) for r in regions],
|
||||
continents=list(conts),
|
||||
)
|
||||
|
||||
|
||||
# ---- Yardımcı: tek görevi topla ----
|
||||
|
||||
async def _load_quest(db: AsyncSession, quest_id: int) -> QuestOut | None:
|
||||
r = (await db.execute(text(
|
||||
"SELECT q.id,q.title,q.quest_type,q.summary,"
|
||||
"q.giver_npc_id,n.name AS giver_npc_name,"
|
||||
"q.continent,q.region_tag_id,t.name AS region_name,"
|
||||
"q.location_tag_id,lt.name AS location_name,"
|
||||
"q.author,q.author_id,q.created_at,q.updated_at "
|
||||
"FROM quests q "
|
||||
"LEFT JOIN npcs n ON n.id=q.giver_npc_id "
|
||||
"LEFT JOIN map_tags t ON t.id=q.region_tag_id "
|
||||
"LEFT JOIN map_tags lt ON lt.id=q.location_tag_id "
|
||||
"WHERE q.id=:id"
|
||||
), {"id": quest_id})).mappings().one_or_none()
|
||||
if r is None:
|
||||
return None
|
||||
objs = (await db.execute(text(
|
||||
"SELECT o.id,o.map_tag_id,mt.name AS location_name,mt.kind AS location_kind,o.note "
|
||||
"FROM quest_objectives o LEFT JOIN map_tags mt ON mt.id=o.map_tag_id "
|
||||
"WHERE o.quest_id=:id ORDER BY o.ordering,o.id"
|
||||
), {"id": quest_id})).mappings().all()
|
||||
d = dict(r)
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
d["updated_at"] = d["updated_at"].isoformat()
|
||||
d["objectives"] = [ObjectiveOut(**dict(o)) for o in objs]
|
||||
return QuestOut(**d)
|
||||
|
||||
|
||||
async def _validate(db: AsyncSession, q: QuestIn):
|
||||
if q.quest_type not in TYPES:
|
||||
raise HTTPException(400, "quest_type: main | side")
|
||||
if q.giver_npc_id is not None:
|
||||
ok = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": q.giver_npc_id})).scalar()
|
||||
if not ok:
|
||||
raise HTTPException(400, "Görev veren NPC yok")
|
||||
if q.region_tag_id is not None:
|
||||
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind='region'"),
|
||||
{"id": q.region_tag_id})).scalar()
|
||||
if not ok:
|
||||
raise HTTPException(400, "Bölge yok")
|
||||
if q.location_tag_id is not None:
|
||||
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
|
||||
{"id": q.location_tag_id})).scalar()
|
||||
if not ok:
|
||||
raise HTTPException(400, "Veriliş noktası yok (map editörü alan/pin olmalı)")
|
||||
for o in q.objectives:
|
||||
if o.map_tag_id is not None:
|
||||
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
|
||||
{"id": o.map_tag_id})).scalar()
|
||||
if not ok:
|
||||
raise HTTPException(400, "Görev noktası yok (map editörü alan/pin olmalı)")
|
||||
|
||||
|
||||
async def _save_objectives(db: AsyncSession, quest_id: int, objectives: list[ObjectiveIn]):
|
||||
await db.execute(text("DELETE FROM quest_objectives WHERE quest_id=:id"), {"id": quest_id})
|
||||
for i, o in enumerate(objectives):
|
||||
if o.map_tag_id is None and not (o.note or "").strip():
|
||||
continue
|
||||
await db.execute(text(
|
||||
"INSERT INTO quest_objectives(quest_id,map_tag_id,note,ordering) "
|
||||
"VALUES(:q,:tag,:note,:ord)"
|
||||
), {"q": quest_id, "tag": o.map_tag_id, "note": (o.note or None), "ord": i})
|
||||
|
||||
|
||||
# ---- CRUD ----
|
||||
|
||||
@router.get("", response_model=list[QuestOut])
|
||||
async def list_quests(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
|
||||
ids = (await db.execute(text(
|
||||
"SELECT id FROM quests ORDER BY quest_type,sort_order,id"
|
||||
))).scalars().all()
|
||||
return [await _load_quest(db, i) for i in ids]
|
||||
|
||||
|
||||
@router.post("", response_model=QuestOut)
|
||||
async def create_quest(q: QuestIn, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _validate(db, q)
|
||||
qid = (await db.execute(text(
|
||||
"INSERT INTO quests(title,quest_type,summary,giver_npc_id,continent,region_tag_id,"
|
||||
"location_tag_id,author,author_id) "
|
||||
"VALUES(:title,:qt,:summary,:giver,:cont,:region,:loc,:author,:aid) RETURNING id"
|
||||
), {"title": q.title, "qt": q.quest_type, "summary": q.summary, "giver": q.giver_npc_id,
|
||||
"cont": q.continent, "region": q.region_tag_id, "loc": q.location_tag_id,
|
||||
"author": _author(user), "aid": user.id})).scalar_one()
|
||||
await _save_objectives(db, qid, q.objectives)
|
||||
await db.commit()
|
||||
return await _load_quest(db, qid)
|
||||
|
||||
|
||||
@router.post("/{quest_id}", response_model=QuestOut)
|
||||
async def update_quest(quest_id: int, q: QuestIn, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, quest_id)
|
||||
await _validate(db, q)
|
||||
res = await db.execute(text(
|
||||
"UPDATE quests SET title=:title,quest_type=:qt,summary=:summary,giver_npc_id=:giver,"
|
||||
"continent=:cont,region_tag_id=:region,location_tag_id=:loc,updated_at=now() WHERE id=:id"
|
||||
), {"id": quest_id, "title": q.title, "qt": q.quest_type, "summary": q.summary,
|
||||
"giver": q.giver_npc_id, "cont": q.continent, "region": q.region_tag_id,
|
||||
"loc": q.location_tag_id})
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Görev yok")
|
||||
await _save_objectives(db, quest_id, q.objectives)
|
||||
await db.commit()
|
||||
return await _load_quest(db, quest_id)
|
||||
|
||||
|
||||
@router.post("/{quest_id}/delete")
|
||||
async def delete_quest(quest_id: int, user: User = Depends(require_perm("lore")),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
await _assert_owner_or_admin(db, user, quest_id)
|
||||
res = await db.execute(text("DELETE FROM quests WHERE id=:id"), {"id": quest_id})
|
||||
await db.commit()
|
||||
if res.rowcount == 0:
|
||||
raise HTTPException(404, "Görev yok")
|
||||
return {"deleted": quest_id}
|
||||
536
routers/travel.py
Normal file
536
routers/travel.py
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
"""
|
||||
Lokasyon-graf dünya: statik harita üzerinde noktalar (şehir/orman/maden...),
|
||||
yollar (adım maliyeti), lokasyonda kaynak toplama ve yolda D&D tarzı olaylar.
|
||||
|
||||
Eski tile/chunk sistemi kaldırıldı (2026-07-04). Akış:
|
||||
- GET /world/map → tüm lokasyonlar + yollar + benim yerim
|
||||
- GET /world/location → bulunduğum yer: kaynaklar (cooldown/tool durumu), buradaki oyuncular, bekleyen olay
|
||||
- POST /world/travel → yol varsa adım öde, git; şansa bağlı olay kartı döner
|
||||
- POST /world/gather → lokasyon kaynağını topla (tool şartı + cooldown + adım + XP)
|
||||
- POST /world/event/resolve → olay kartında seçim yap (skill check d20 + skill seviyesi)
|
||||
"""
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import get_current_user
|
||||
from game_logic import character_level_from_total_xp, skill_level_from_xp
|
||||
from models import Character, Skill, User, get_db
|
||||
from routers.harvest import SKILL_TOOLS, TOOL_LABEL, _has_any_tool, consume_items
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["travel"])
|
||||
|
||||
# Kamp: lokasyonda kişisel kamp → o lokasyonda gather adım maliyeti -%25
|
||||
CAMP_COST_ITEMS = {"log_oak": 5, "stone_small": 2, "vine": 1}
|
||||
CAMP_STEP_COST = 100
|
||||
CAMP_XP = 40 # construction
|
||||
CAMP_DISCOUNT = 0.25
|
||||
|
||||
|
||||
def _camp_cost(step_cost: int, has_camp: bool) -> int:
|
||||
if not has_camp:
|
||||
return step_cost
|
||||
return max(1, int(step_cost * (1.0 - CAMP_DISCOUNT)))
|
||||
|
||||
|
||||
async def _has_camp(db: AsyncSession, char_id: int, location_id: int | None) -> bool:
|
||||
if location_id is None:
|
||||
return False
|
||||
row = (await db.execute(text(
|
||||
"SELECT 1 FROM camps WHERE character_id=:c AND location_id=:l"
|
||||
), {"c": char_id, "l": location_id})).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
async def _char(db: AsyncSession, user: User) -> Character:
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
return char
|
||||
|
||||
|
||||
async def _add_items(db: AsyncSession, char_id: int, item_code: str, qty: int) -> None:
|
||||
"""Envantere stack mantığıyla item ekle."""
|
||||
row = (await db.execute(
|
||||
text("SELECT id, stack_max FROM items WHERE code = :c"), {"c": item_code}
|
||||
)).first()
|
||||
if not row:
|
||||
return
|
||||
item_id, stack_max = row[0], int(row[1])
|
||||
remaining = qty
|
||||
stacks = (await db.execute(text(
|
||||
"SELECT id, qty FROM inventory WHERE character_id=:cid AND item_id=:iid ORDER BY slot"
|
||||
), {"cid": char_id, "iid": item_id})).all()
|
||||
for st in stacks:
|
||||
if remaining <= 0:
|
||||
return
|
||||
space = stack_max - st[1]
|
||||
if space <= 0:
|
||||
continue
|
||||
add = min(space, remaining)
|
||||
await db.execute(text("UPDATE inventory SET qty = qty + :a WHERE id = :id"), {"a": add, "id": st[0]})
|
||||
remaining -= add
|
||||
while remaining > 0:
|
||||
add = min(stack_max, remaining)
|
||||
slot = (await db.execute(text(
|
||||
"SELECT COALESCE(MAX(slot), -1) + 1 FROM inventory WHERE character_id=:cid"
|
||||
), {"cid": char_id})).first()[0]
|
||||
await db.execute(text(
|
||||
"INSERT INTO inventory (character_id, item_id, qty, slot) VALUES (:cid, :iid, :q, :s)"
|
||||
), {"cid": char_id, "iid": item_id, "q": add, "s": slot})
|
||||
remaining -= add
|
||||
|
||||
|
||||
def _grant_xp(char: Character, skill: Skill, xp: int) -> dict:
|
||||
old_sl, old_cl = skill.level, char.level
|
||||
skill.xp += xp
|
||||
skill.level = skill_level_from_xp(skill.xp)
|
||||
char.total_xp += xp
|
||||
char.level = character_level_from_total_xp(char.total_xp)
|
||||
return {"skill_level_up": skill.level > old_sl, "character_level_up": char.level > old_cl}
|
||||
|
||||
|
||||
# ---------- Harita ----------
|
||||
|
||||
class LocationOut(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
type: str
|
||||
description: Optional[str] = None
|
||||
map_x: int
|
||||
map_y: int
|
||||
|
||||
|
||||
class RoadOut(BaseModel):
|
||||
a: str
|
||||
b: str
|
||||
steps: int
|
||||
waypoints: list[list[float]] = [] # normalize 0-1 ara noktalar (editörde çizilir)
|
||||
|
||||
|
||||
class MapOut(BaseModel):
|
||||
locations: list[LocationOut]
|
||||
roads: list[RoadOut]
|
||||
my_location: Optional[str] = None
|
||||
pending_event: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("/map", response_model=MapOut)
|
||||
async def world_map(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
locs = (await db.execute(text(
|
||||
"SELECT id, code, name, type, description, map_x, map_y FROM locations ORDER BY id"
|
||||
))).all()
|
||||
id2code = {r[0]: r[1] for r in locs}
|
||||
roads = (await db.execute(text("SELECT loc_a, loc_b, step_cost, waypoints FROM roads"))).all()
|
||||
return MapOut(
|
||||
locations=[LocationOut(code=r[1], name=r[2], type=r[3], description=r[4], map_x=r[5], map_y=r[6]) for r in locs],
|
||||
roads=[RoadOut(a=id2code[r[0]], b=id2code[r[1]], steps=r[2], waypoints=r[3] or []) for r in roads],
|
||||
my_location=id2code.get(char.location_id),
|
||||
pending_event=char.pending_event,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Lokasyon detay ----------
|
||||
|
||||
class ResourceOut(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
skill: str
|
||||
step_cost: int
|
||||
xp: int
|
||||
cooldown_remaining: int # saniye, 0 = hazır
|
||||
tool_ok: bool
|
||||
tool_needed: Optional[str] = None
|
||||
|
||||
|
||||
class LocationDetailOut(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
type: str
|
||||
description: Optional[str] = None
|
||||
resources: list[ResourceOut]
|
||||
players_here: list[str]
|
||||
pending_event: Optional[dict] = None
|
||||
has_camp: bool = False
|
||||
camp_cost_items: dict = {} # kamp yoksa: kurulum malzemeleri {code: qty}
|
||||
camp_step_cost: int = CAMP_STEP_COST
|
||||
|
||||
|
||||
@router.get("/location", response_model=LocationDetailOut)
|
||||
async def location_detail(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
if char.location_id is None:
|
||||
raise HTTPException(400, "Konumun yok — sunucu seed'i eksik olabilir")
|
||||
loc = (await db.execute(text(
|
||||
"SELECT id, code, name, type, description FROM locations WHERE id = :i"
|
||||
), {"i": char.location_id})).first()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = (await db.execute(text("""
|
||||
SELECT lr.id, lr.item_code, i.name, lr.skill, lr.step_cost, lr.xp, gc.ready_at
|
||||
FROM location_resources lr
|
||||
JOIN items i ON i.code = lr.item_code
|
||||
LEFT JOIN gather_cooldowns gc ON gc.resource_id = lr.id AND gc.character_id = :cid
|
||||
WHERE lr.location_id = :lid
|
||||
ORDER BY lr.step_cost
|
||||
"""), {"cid": char.id, "lid": char.location_id})).all()
|
||||
|
||||
has_camp = await _has_camp(db, char.id, char.location_id)
|
||||
resources = []
|
||||
for r in rows:
|
||||
tool_codes = SKILL_TOOLS.get(r[3])
|
||||
tool_ok = True
|
||||
if tool_codes:
|
||||
tool_ok = await _has_any_tool(db, char.id, tool_codes)
|
||||
cd = 0
|
||||
if r[6] is not None and r[6] > now:
|
||||
cd = int((r[6] - now).total_seconds())
|
||||
resources.append(ResourceOut(
|
||||
item_code=r[1], item_name=r[2], skill=r[3],
|
||||
step_cost=_camp_cost(int(r[4]), has_camp), xp=r[5],
|
||||
cooldown_remaining=cd, tool_ok=tool_ok,
|
||||
tool_needed=TOOL_LABEL.get(r[3]),
|
||||
))
|
||||
|
||||
names = (await db.execute(text(
|
||||
"SELECT name FROM characters WHERE location_id = :lid AND id != :cid ORDER BY name LIMIT 50"
|
||||
), {"lid": char.location_id, "cid": char.id})).all()
|
||||
|
||||
return LocationDetailOut(
|
||||
code=loc[1], name=loc[2], type=loc[3], description=loc[4],
|
||||
resources=resources,
|
||||
players_here=[n[0] for n in names],
|
||||
pending_event=char.pending_event,
|
||||
has_camp=has_camp,
|
||||
camp_cost_items={} if has_camp else CAMP_COST_ITEMS,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Seyahat ----------
|
||||
|
||||
class TravelIn(BaseModel):
|
||||
to_code: str
|
||||
|
||||
|
||||
class TravelOut(BaseModel):
|
||||
location_code: str
|
||||
location_name: str
|
||||
step_cost: int
|
||||
step_balance: int
|
||||
event: Optional[dict] = None # {code, name, text, choices:[{text}]} — çözülmesi gerekir
|
||||
|
||||
|
||||
@router.post("/travel", response_model=TravelOut)
|
||||
async def travel(t: TravelIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
if char.pending_event:
|
||||
raise HTTPException(400, "Önce bekleyen olayı çöz")
|
||||
|
||||
dest = (await db.execute(text(
|
||||
"SELECT id, code, name FROM locations WHERE code = :c"
|
||||
), {"c": t.to_code})).first()
|
||||
if not dest:
|
||||
raise HTTPException(404, "Böyle bir yer yok")
|
||||
if dest[0] == char.location_id:
|
||||
raise HTTPException(400, "Zaten oradasın")
|
||||
|
||||
road = (await db.execute(text("""
|
||||
SELECT step_cost, event_chance FROM roads
|
||||
WHERE (loc_a = :a AND loc_b = :b) OR (loc_a = :b AND loc_b = :a)
|
||||
"""), {"a": char.location_id, "b": dest[0]})).first()
|
||||
if not road:
|
||||
raise HTTPException(400, "Oraya buradan yol yok")
|
||||
|
||||
cost = int(road[0])
|
||||
if char.step_balance < cost:
|
||||
raise HTTPException(400, f"Yetersiz adım: {cost} gerekli, {char.step_balance} var")
|
||||
|
||||
char.step_balance -= cost
|
||||
char.location_id = dest[0]
|
||||
char.last_sync_at = datetime.now(timezone.utc)
|
||||
|
||||
# Yolda olay? (d100 < şans). %50 önce varış lokasyonuna özel havuz denenir,
|
||||
# yoksa/diğer yarıda genel + özel karışık havuzdan gelir.
|
||||
event_payload = None
|
||||
if random.random() < float(road[1]):
|
||||
ev = None
|
||||
if random.random() < 0.5:
|
||||
ev = (await db.execute(text(
|
||||
"SELECT code, name, text, choices FROM events "
|
||||
"WHERE :dest = ANY(location_codes) ORDER BY random() LIMIT 1"
|
||||
), {"dest": dest[1]})).first()
|
||||
if ev is None:
|
||||
ev = (await db.execute(text(
|
||||
"SELECT code, name, text, choices FROM events "
|
||||
"WHERE location_codes IS NULL OR :dest = ANY(location_codes) "
|
||||
"ORDER BY random() LIMIT 1"
|
||||
), {"dest": dest[1]})).first()
|
||||
if ev:
|
||||
# Oyuncuya sadece seçim metinleri gider; sonuçlar sunucuda kalır
|
||||
choices_client = [{"text": c["text"]} for c in ev[3]]
|
||||
event_payload = {"code": ev[0], "name": ev[1], "text": ev[2], "choices": choices_client}
|
||||
# Tam kart saklanır ki istemci reconnect'te de render edebilsin
|
||||
char.pending_event = event_payload
|
||||
|
||||
await db.commit()
|
||||
|
||||
# WS yayını
|
||||
from routers.ws import publish_event, set_online
|
||||
payload = {"char_id": char.id, "name": char.name, "level": char.level, "location_code": dest[1]}
|
||||
await set_online(char.id, payload)
|
||||
await publish_event({"type": "player_moved", **payload})
|
||||
|
||||
return TravelOut(
|
||||
location_code=dest[1], location_name=dest[2],
|
||||
step_cost=cost, step_balance=char.step_balance,
|
||||
event=event_payload,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Kaynak toplama ----------
|
||||
|
||||
class GatherIn(BaseModel):
|
||||
item_code: str
|
||||
|
||||
|
||||
class GatherOut(BaseModel):
|
||||
item_code: str
|
||||
item_name: str
|
||||
qty_gained: int
|
||||
skill: str
|
||||
xp_gained: int
|
||||
skill_level: int
|
||||
skill_level_up: bool
|
||||
total_xp: int
|
||||
character_level: int
|
||||
character_level_up: bool
|
||||
step_balance: int
|
||||
cooldown_s: int
|
||||
|
||||
|
||||
@router.post("/gather", response_model=GatherOut)
|
||||
async def gather(g: GatherIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
if char.pending_event:
|
||||
raise HTTPException(400, "Önce bekleyen olayı çöz")
|
||||
|
||||
res = (await db.execute(text("""
|
||||
SELECT lr.id, lr.item_code, i.name, lr.skill, lr.step_cost, lr.xp, lr.cooldown_s
|
||||
FROM location_resources lr JOIN items i ON i.code = lr.item_code
|
||||
WHERE lr.location_id = :lid AND lr.item_code = :c
|
||||
"""), {"lid": char.location_id, "c": g.item_code})).first()
|
||||
if not res:
|
||||
raise HTTPException(404, "Bu kaynak burada yok")
|
||||
|
||||
# Tool şartı
|
||||
tool_codes = SKILL_TOOLS.get(res[3])
|
||||
if tool_codes and not await _has_any_tool(db, char.id, tool_codes):
|
||||
raise HTTPException(400, f"{TOOL_LABEL[res[3]]} gerekli — envanterinde yok")
|
||||
|
||||
# Cooldown
|
||||
now = datetime.now(timezone.utc)
|
||||
cd = (await db.execute(text(
|
||||
"SELECT ready_at FROM gather_cooldowns WHERE character_id=:cid AND resource_id=:rid"
|
||||
), {"cid": char.id, "rid": res[0]})).first()
|
||||
if cd and cd[0] > now:
|
||||
raise HTTPException(400, f"Bu kaynak yenileniyor ({int((cd[0] - now).total_seconds())}s)")
|
||||
|
||||
step_cost = _camp_cost(int(res[4]), await _has_camp(db, char.id, char.location_id))
|
||||
if char.step_balance < step_cost:
|
||||
raise HTTPException(400, f"Yetersiz adım: {step_cost} gerekli, {char.step_balance} var")
|
||||
|
||||
skill = (await db.execute(
|
||||
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == res[3])
|
||||
)).scalar_one_or_none()
|
||||
if skill is None:
|
||||
skill = Skill(character_id=char.id, skill_name=res[3], xp=0, level=1)
|
||||
db.add(skill)
|
||||
await db.flush()
|
||||
|
||||
char.step_balance -= step_cost
|
||||
ups = _grant_xp(char, skill, int(res[5]))
|
||||
await _add_items(db, char.id, res[1], 1)
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO gather_cooldowns (character_id, resource_id, ready_at)
|
||||
VALUES (:cid, :rid, :ra)
|
||||
ON CONFLICT (character_id, resource_id) DO UPDATE SET ready_at = EXCLUDED.ready_at
|
||||
"""), {"cid": char.id, "rid": res[0], "ra": now + timedelta(seconds=int(res[6]))})
|
||||
|
||||
char.last_sync_at = now
|
||||
await db.commit()
|
||||
|
||||
return GatherOut(
|
||||
item_code=res[1], item_name=res[2], qty_gained=1,
|
||||
skill=res[3], xp_gained=int(res[5]),
|
||||
skill_level=skill.level, skill_level_up=ups["skill_level_up"],
|
||||
total_xp=char.total_xp, character_level=char.level,
|
||||
character_level_up=ups["character_level_up"],
|
||||
step_balance=char.step_balance, cooldown_s=int(res[6]),
|
||||
)
|
||||
|
||||
|
||||
# ---------- Kamp kurma ----------
|
||||
|
||||
class CampBuildOut(BaseModel):
|
||||
location_code: str
|
||||
step_cost: int
|
||||
xp_gained: int
|
||||
skill: str = "construction"
|
||||
skill_level: int
|
||||
skill_level_up: bool
|
||||
character_level: int
|
||||
character_level_up: bool
|
||||
step_balance: int
|
||||
|
||||
|
||||
@router.post("/camp/build", response_model=CampBuildOut)
|
||||
async def build_camp(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
if char.pending_event:
|
||||
raise HTTPException(400, "Önce bekleyen olayı çöz")
|
||||
if char.location_id is None:
|
||||
raise HTTPException(400, "Konumun yok")
|
||||
if await _has_camp(db, char.id, char.location_id):
|
||||
raise HTTPException(400, "Burada zaten kampın var")
|
||||
if char.step_balance < CAMP_STEP_COST:
|
||||
raise HTTPException(400, f"Yetersiz adım: {CAMP_STEP_COST} gerekli, {char.step_balance} var")
|
||||
|
||||
# Malzemeler (hepsi birden — yetersizse consume_items 400 fırlatır, rollback olur)
|
||||
for code, qty in CAMP_COST_ITEMS.items():
|
||||
item = (await db.execute(text(
|
||||
"SELECT id, name FROM items WHERE code=:c"), {"c": code})).first()
|
||||
await consume_items(db, char.id, item[0], qty, item[1])
|
||||
|
||||
skill = (await db.execute(
|
||||
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == "construction")
|
||||
)).scalar_one_or_none()
|
||||
if skill is None:
|
||||
skill = Skill(character_id=char.id, skill_name="construction", xp=0, level=1)
|
||||
db.add(skill)
|
||||
await db.flush()
|
||||
|
||||
char.step_balance -= CAMP_STEP_COST
|
||||
ups = _grant_xp(char, skill, CAMP_XP)
|
||||
await db.execute(text(
|
||||
"INSERT INTO camps (character_id, location_id) VALUES (:c, :l)"
|
||||
), {"c": char.id, "l": char.location_id})
|
||||
|
||||
loc_code = (await db.execute(text(
|
||||
"SELECT code FROM locations WHERE id=:i"), {"i": char.location_id})).first()[0]
|
||||
char.last_sync_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
return CampBuildOut(
|
||||
location_code=loc_code, step_cost=CAMP_STEP_COST, xp_gained=CAMP_XP,
|
||||
skill_level=skill.level, skill_level_up=ups["skill_level_up"],
|
||||
character_level=char.level, character_level_up=ups["character_level_up"],
|
||||
step_balance=char.step_balance,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Olay çözümü ----------
|
||||
|
||||
class ResolveIn(BaseModel):
|
||||
choice: int = Field(ge=0, le=5)
|
||||
|
||||
|
||||
class ResolveOut(BaseModel):
|
||||
event_code: str
|
||||
choice_text: str
|
||||
skill_check: Optional[dict] = None # {skill, dc, roll, skill_level, success}
|
||||
result_text: str
|
||||
steps_delta: int
|
||||
gold_delta: int = 0
|
||||
items_gained: list[dict]
|
||||
xp_gained: Optional[dict] = None # {skill, xp}
|
||||
step_balance: int
|
||||
gold: int = 0
|
||||
|
||||
|
||||
@router.post("/event/resolve", response_model=ResolveOut)
|
||||
async def resolve_event(r: ResolveIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = await _char(db, user)
|
||||
if not char.pending_event:
|
||||
raise HTTPException(400, "Bekleyen olay yok")
|
||||
|
||||
ev = (await db.execute(text(
|
||||
"SELECT code, name, choices FROM events WHERE code = :c"
|
||||
), {"c": char.pending_event.get("code")})).first()
|
||||
if not ev:
|
||||
char.pending_event = None
|
||||
await db.commit()
|
||||
raise HTTPException(500, "Olay bulunamadı, temizlendi")
|
||||
|
||||
choices = ev[2]
|
||||
if r.choice >= len(choices):
|
||||
raise HTTPException(400, "Geçersiz seçim")
|
||||
ch = choices[r.choice]
|
||||
|
||||
# Skill check'li mi?
|
||||
skill_check = None
|
||||
if "skill" in ch:
|
||||
skill = (await db.execute(
|
||||
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == ch["skill"])
|
||||
)).scalar_one_or_none()
|
||||
lvl = skill.level if skill else 1
|
||||
roll = random.randint(1, 20)
|
||||
success = roll + lvl >= int(ch["dc"])
|
||||
skill_check = {"skill": ch["skill"], "dc": int(ch["dc"]), "roll": roll,
|
||||
"skill_level": lvl, "success": success}
|
||||
outcome = ch["success"] if success else ch["fail"]
|
||||
else:
|
||||
outcome = ch["outcome"]
|
||||
|
||||
# Sonucu uygula
|
||||
steps_delta = int(outcome.get("steps", 0))
|
||||
if steps_delta < 0:
|
||||
steps_delta = -min(-steps_delta, char.step_balance) # bakiye eksiye düşmez
|
||||
char.step_balance += steps_delta
|
||||
|
||||
gold_delta = int(outcome.get("gold", 0))
|
||||
if gold_delta < 0:
|
||||
gold_delta = -min(-gold_delta, char.gold)
|
||||
char.gold += gold_delta
|
||||
|
||||
items_gained = []
|
||||
for it in outcome.get("items", []):
|
||||
await _add_items(db, char.id, it["code"], int(it["qty"]))
|
||||
name = (await db.execute(text("SELECT name FROM items WHERE code=:c"), {"c": it["code"]})).first()
|
||||
items_gained.append({"code": it["code"], "name": name[0] if name else it["code"], "qty": int(it["qty"])})
|
||||
|
||||
xp_gained = None
|
||||
if "xp" in outcome:
|
||||
skill_name = outcome["xp"]["skill"]
|
||||
skill = (await db.execute(
|
||||
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == skill_name)
|
||||
)).scalar_one_or_none()
|
||||
if skill is None:
|
||||
skill = Skill(character_id=char.id, skill_name=skill_name, xp=0, level=1)
|
||||
db.add(skill)
|
||||
await db.flush()
|
||||
_grant_xp(char, skill, int(outcome["xp"]["amount"]))
|
||||
xp_gained = {"skill": skill_name, "xp": int(outcome["xp"]["amount"])}
|
||||
|
||||
char.pending_event = None
|
||||
char.last_sync_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
return ResolveOut(
|
||||
event_code=ev[0],
|
||||
choice_text=ch["text"],
|
||||
skill_check=skill_check,
|
||||
result_text=outcome.get("text", ""),
|
||||
steps_delta=steps_delta,
|
||||
gold_delta=gold_delta,
|
||||
items_gained=items_gained,
|
||||
xp_gained=xp_gained,
|
||||
step_balance=char.step_balance,
|
||||
gold=char.gold,
|
||||
)
|
||||
194
routers/world.py
Normal file
194
routers/world.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""
|
||||
World: pedometer adım senkronizasyonu + günlük hedef/streak.
|
||||
|
||||
NOT (2026-07-04): Tile/chunk sistemi kaldırıldı — dünya artık lokasyon-graf
|
||||
(bkz. routers/travel.py: /world/map, /world/travel, /world/gather, /world/event/resolve).
|
||||
"""
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import get_current_user
|
||||
from models import Character, User, get_db
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["world"])
|
||||
|
||||
# Pedometer adım senkronizasyonu (gerçek cihaz)
|
||||
STEP_CAP_PER_MIN = 250 # dakika başı kabul edilen maks adım (koşu dahil)
|
||||
STEP_SYNC_GRACE = 500 # her sync'te elapsed-tavanına eklenen sabit pay
|
||||
STEP_FIRST_SYNC_MAX = 5000 # ilk sync / last_sync_at boşken kabul tavanı
|
||||
STEP_SYNC_MIN_INTERVAL = 10 # iki sync arası min saniye (spam koruması)
|
||||
|
||||
# Günlük hedef + streak
|
||||
DAILY_GOAL = 3000 # günlük gerçek adım hedefi
|
||||
DAILY_BONUS_BASE = 200 # hedef ödülü (adım bakiyesi)
|
||||
DAILY_BONUS_PER_STREAK = 50 # streak günü başına ek (7 günde tavan)
|
||||
DAILY_STREAK_CAP = 7
|
||||
DAILY_GOLD_BONUS = 5 # hedef ödülü (gold)
|
||||
|
||||
|
||||
def _roll_daily(char: Character, today: date) -> None:
|
||||
"""Gün değiştiyse günlük sayacı sıfırla; ödül dünden alınmadıysa streak kırılır."""
|
||||
if char.daily_date != today:
|
||||
char.daily_date = today
|
||||
char.daily_steps = 0
|
||||
if char.last_claim_date is not None and (today - char.last_claim_date).days > 1:
|
||||
char.streak_days = 0
|
||||
|
||||
|
||||
class SyncStepsIn(BaseModel):
|
||||
delta: int = Field(ge=0, le=200000) # son sync'ten beri eklenen adım
|
||||
source: str = Field("unknown", max_length=32)
|
||||
|
||||
|
||||
class SyncStepsOut(BaseModel):
|
||||
step_balance: int
|
||||
step_lifetime: int
|
||||
accepted: int
|
||||
rejected: int
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/sync-steps", response_model=SyncStepsOut)
|
||||
async def sync_steps(d: SyncStepsIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
last = char.last_sync_at
|
||||
reason = None
|
||||
|
||||
# tavan hesabı
|
||||
if last is None:
|
||||
cap = STEP_FIRST_SYNC_MAX
|
||||
else:
|
||||
elapsed = (now - last).total_seconds()
|
||||
if elapsed < STEP_SYNC_MIN_INTERVAL and d.delta > 0:
|
||||
# çok sık sync — yine de kabul et ama küçük tavan uygula
|
||||
elapsed = STEP_SYNC_MIN_INTERVAL
|
||||
cap = int(elapsed / 60.0 * STEP_CAP_PER_MIN) + STEP_SYNC_GRACE
|
||||
|
||||
accepted = min(d.delta, cap)
|
||||
rejected = d.delta - accepted
|
||||
if rejected > 0:
|
||||
reason = "cap_exceeded: delta=%d cap=%d" % (d.delta, cap)
|
||||
|
||||
_roll_daily(char, now.date())
|
||||
if accepted > 0:
|
||||
char.step_balance += accepted
|
||||
char.step_lifetime += accepted
|
||||
char.daily_steps += accepted
|
||||
char.last_sync_at = now
|
||||
|
||||
src = (d.source or "unknown")[:32]
|
||||
if d.delta > 0:
|
||||
await db.execute(text(
|
||||
"INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
|
||||
"VALUES (:cid, :sd, :src, :acc, :rsn)"
|
||||
), {"cid": char.id, "sd": d.delta, "src": src, "acc": rejected == 0, "rsn": reason})
|
||||
|
||||
await db.commit()
|
||||
return SyncStepsOut(
|
||||
step_balance=char.step_balance,
|
||||
step_lifetime=char.step_lifetime,
|
||||
accepted=accepted,
|
||||
rejected=rejected,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
# DEBUG: adım yükleme (pedometer gelene kadar test için)
|
||||
class DebugAddStepsIn(BaseModel):
|
||||
steps: int = Field(gt=0, le=10000)
|
||||
|
||||
|
||||
@router.post("/debug/add-steps", response_model=dict)
|
||||
async def debug_add_steps(d: DebugAddStepsIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
_roll_daily(char, datetime.now(timezone.utc).date())
|
||||
char.step_balance += d.steps
|
||||
char.step_lifetime += d.steps
|
||||
char.daily_steps += d.steps
|
||||
await db.commit()
|
||||
return {"step_balance": char.step_balance, "step_lifetime": char.step_lifetime}
|
||||
|
||||
|
||||
# ---------- Günlük hedef + streak ----------
|
||||
|
||||
class DailyOut(BaseModel):
|
||||
goal: int
|
||||
today_steps: int
|
||||
streak_days: int
|
||||
claimable: bool
|
||||
claimed_today: bool
|
||||
bonus_steps: int # şu anki streak'e göre bugünkü ödül
|
||||
bonus_gold: int
|
||||
|
||||
|
||||
def _bonus_for(streak_after_claim: int) -> int:
|
||||
return DAILY_BONUS_BASE + DAILY_BONUS_PER_STREAK * min(streak_after_claim, DAILY_STREAK_CAP)
|
||||
|
||||
|
||||
def _daily_out(char: Character, today: date) -> DailyOut:
|
||||
claimed_today = char.last_claim_date == today
|
||||
claimable = (not claimed_today) and char.daily_steps >= DAILY_GOAL
|
||||
next_streak = char.streak_days if claimed_today else char.streak_days + 1
|
||||
return DailyOut(
|
||||
goal=DAILY_GOAL, today_steps=char.daily_steps, streak_days=char.streak_days,
|
||||
claimable=claimable, claimed_today=claimed_today,
|
||||
bonus_steps=_bonus_for(next_streak), bonus_gold=DAILY_GOLD_BONUS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyOut)
|
||||
async def daily_status(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
today = datetime.now(timezone.utc).date()
|
||||
_roll_daily(char, today)
|
||||
await db.commit()
|
||||
return _daily_out(char, today)
|
||||
|
||||
|
||||
class DailyClaimOut(BaseModel):
|
||||
streak_days: int
|
||||
bonus_steps: int
|
||||
bonus_gold: int
|
||||
step_balance: int
|
||||
gold: int
|
||||
|
||||
|
||||
@router.post("/daily/claim", response_model=DailyClaimOut)
|
||||
async def daily_claim(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
today = datetime.now(timezone.utc).date()
|
||||
_roll_daily(char, today)
|
||||
if char.last_claim_date == today:
|
||||
raise HTTPException(400, "Bugünkü ödülü zaten aldın")
|
||||
if char.daily_steps < DAILY_GOAL:
|
||||
raise HTTPException(400, f"Hedef dolmadı: {char.daily_steps}/{DAILY_GOAL}")
|
||||
|
||||
char.streak_days += 1
|
||||
char.last_claim_date = today
|
||||
bonus = _bonus_for(char.streak_days)
|
||||
char.step_balance += bonus
|
||||
char.gold += DAILY_GOLD_BONUS
|
||||
await db.execute(text(
|
||||
"INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
|
||||
"VALUES (:cid, :sd, 'daily_bonus', true, :rsn)"
|
||||
), {"cid": char.id, "sd": bonus, "rsn": f"streak={char.streak_days}"})
|
||||
await db.commit()
|
||||
return DailyClaimOut(
|
||||
streak_days=char.streak_days, bonus_steps=bonus, bonus_gold=DAILY_GOLD_BONUS,
|
||||
step_balance=char.step_balance, gold=char.gold,
|
||||
)
|
||||
280
routers/ws.py
Normal file
280
routers/ws.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""
|
||||
WebSocket multiplayer — canlı oyuncu pozisyonları.
|
||||
|
||||
Mimari: uvicorn 2 worker ile çalıştığı için bağlantılar süreçlere dağılır;
|
||||
eventler Redis pub/sub kanalı üzerinden tüm worker'lara yayılır, her worker
|
||||
kendi yerel bağlantılarına iletir. Online varlık (presence) Redis'te
|
||||
TTL'li key olarak tutulur (heartbeat ile yenilenir).
|
||||
|
||||
Event türleri (JSON, hepsinde "type" alanı):
|
||||
- online_list → sadece yeni bağlanana: {"players": [player, ...]}
|
||||
- chat_history → sadece yeni bağlanana: {"messages": [chat, ...]} (son 30, eskiden yeniye)
|
||||
- player_joined / player_moved → player payload (char_id, name, level, chunk_x, chunk_y, x, y)
|
||||
- player_left → {"char_id": int}
|
||||
- chat → {"char_id", "name", "text", "ts", "channel", "location_code?"} (gönderene de gider)
|
||||
- pong → ping yanıtı
|
||||
|
||||
İstemciden gelenler:
|
||||
- {"type":"ping"}
|
||||
- {"type":"chat","text":"...","channel":"global"|"local"} (local = sadece aynı lokasyondakilere)
|
||||
- {"type":"get_local_history"} → bulunduğun lokasyonun son mesajları (chat_history, channel=local)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from cache import redis_client
|
||||
from models import AsyncSessionLocal, Character
|
||||
from security import decode_token
|
||||
|
||||
log = logging.getLogger("stepstead.ws")
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
CHANNEL = "ss:world_events"
|
||||
ONLINE_KEY_PREFIX = "ss:online:"
|
||||
ONLINE_TTL = 120 # saniye; istemci ping'i (25sn) ile yenilenir
|
||||
|
||||
CHAT_CHANNEL = "global"
|
||||
CHAT_MAX_LEN = 300
|
||||
CHAT_HISTORY_LIMIT = 30
|
||||
CHAT_MIN_INTERVAL = 1.0 # saniye — basit flood koruması
|
||||
|
||||
# Bu event türleri üreticisine geri gönderilmez (chat gönderene de gider)
|
||||
NO_ECHO_TYPES = {"player_joined", "player_moved", "player_left"}
|
||||
|
||||
|
||||
def player_payload(char: Character, location_code: str | None) -> dict:
|
||||
return {
|
||||
"char_id": char.id,
|
||||
"name": char.name,
|
||||
"level": char.level,
|
||||
"location_code": location_code,
|
||||
}
|
||||
|
||||
|
||||
async def publish_event(event: dict) -> None:
|
||||
try:
|
||||
await redis_client.publish(CHANNEL, json.dumps(event))
|
||||
except Exception:
|
||||
log.exception("Redis publish başarısız")
|
||||
|
||||
|
||||
async def set_online(char_id: int, data: dict) -> None:
|
||||
try:
|
||||
await redis_client.set(ONLINE_KEY_PREFIX + str(char_id), json.dumps(data), ex=ONLINE_TTL)
|
||||
except Exception:
|
||||
log.exception("Online key yazılamadı")
|
||||
|
||||
|
||||
class Hub:
|
||||
"""Bu worker'a bağlı WebSocket'ler + Redis pub/sub dinleyicisi."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.local: dict[int, WebSocket] = {}
|
||||
# char_id → lokasyon kodu (yerel chat filtrelemesi için; player_moved ile güncellenir)
|
||||
self.locs: dict[int, str | None] = {}
|
||||
self._listener: asyncio.Task | None = None
|
||||
|
||||
def ensure_listener(self) -> None:
|
||||
if self._listener is None or self._listener.done():
|
||||
self._listener = asyncio.create_task(self._listen())
|
||||
|
||||
async def _listen(self) -> None:
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe(CHANNEL)
|
||||
try:
|
||||
async for msg in pubsub.listen():
|
||||
if msg["type"] != "message":
|
||||
continue
|
||||
await self._broadcast_local(msg["data"])
|
||||
except Exception:
|
||||
log.exception("pub/sub dinleyici düştü")
|
||||
finally:
|
||||
await pubsub.aclose()
|
||||
|
||||
async def _broadcast_local(self, raw: str) -> None:
|
||||
# Pozisyon eventlerini üreten oyuncuya geri gönderme (echo önleme)
|
||||
origin: int | None = None
|
||||
local_chat_loc: str | None = None
|
||||
try:
|
||||
evt = json.loads(raw)
|
||||
etype = evt.get("type")
|
||||
if etype in NO_ECHO_TYPES:
|
||||
origin = evt.get("char_id")
|
||||
# Seyahat eden oyuncunun lokasyonunu takip et (yerel chat için)
|
||||
if etype == "player_moved" and evt.get("char_id") in self.locs:
|
||||
self.locs[evt["char_id"]] = evt.get("location_code")
|
||||
if etype == "chat" and evt.get("channel") == "local":
|
||||
local_chat_loc = evt.get("location_code")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
dead: list[int] = []
|
||||
for cid, ws in list(self.local.items()):
|
||||
if cid == origin:
|
||||
continue
|
||||
# Yerel chat sadece aynı lokasyondakilere gider
|
||||
if local_chat_loc is not None and self.locs.get(cid) != local_chat_loc:
|
||||
continue
|
||||
try:
|
||||
await ws.send_text(raw)
|
||||
except Exception:
|
||||
dead.append(cid)
|
||||
for cid in dead:
|
||||
self.local.pop(cid, None)
|
||||
|
||||
|
||||
hub = Hub()
|
||||
|
||||
|
||||
async def _chat_history(channel_key: str = CHAT_CHANNEL) -> list[dict]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
rows = (await db.execute(text("""
|
||||
SELECT m.sender_id, c.name, m.text, m.ts
|
||||
FROM chat_messages m JOIN characters c ON c.id = m.sender_id
|
||||
WHERE m.channel = :ch
|
||||
ORDER BY m.ts DESC LIMIT :lim
|
||||
"""), {"ch": channel_key, "lim": CHAT_HISTORY_LIMIT})).all()
|
||||
return [
|
||||
{"char_id": r[0], "name": r[1], "text": r[2], "ts": r[3].isoformat()}
|
||||
for r in reversed(rows)
|
||||
]
|
||||
|
||||
|
||||
async def _save_chat(char_id: int, chat_text: str, channel_key: str = CHAT_CHANNEL) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(
|
||||
text("INSERT INTO chat_messages (channel, sender_id, text) VALUES (:ch, :sid, :t)"),
|
||||
{"ch": channel_key, "sid": char_id, "t": chat_text},
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def world_ws(ws: WebSocket):
|
||||
# Auth: token query param (WebSocket'te Authorization header her istemcide desteklenmez)
|
||||
token = ws.query_params.get("token", "")
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id = int(payload["sub"])
|
||||
except Exception:
|
||||
await ws.close(code=4401)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
char = (await db.execute(
|
||||
select(Character).where(Character.user_id == user_id)
|
||||
)).scalar_one_or_none()
|
||||
loc_code = None
|
||||
if char is not None and char.location_id is not None:
|
||||
row = (await db.execute(
|
||||
text("SELECT code FROM locations WHERE id = :i"), {"i": char.location_id}
|
||||
)).first()
|
||||
loc_code = row[0] if row else None
|
||||
if char is None:
|
||||
await ws.close(code=4404)
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
hub.ensure_listener()
|
||||
hub.local[char.id] = ws
|
||||
hub.locs[char.id] = loc_code
|
||||
me = player_payload(char, loc_code)
|
||||
|
||||
# Mevcut online oyuncular sadece bu istemciye
|
||||
online: list[dict] = []
|
||||
try:
|
||||
async for key in redis_client.scan_iter(match=ONLINE_KEY_PREFIX + "*"):
|
||||
raw = await redis_client.get(key)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
p = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if p.get("char_id") != char.id:
|
||||
online.append(p)
|
||||
except Exception:
|
||||
log.exception("Online liste okunamadı")
|
||||
await ws.send_text(json.dumps({"type": "online_list", "players": online}))
|
||||
|
||||
try:
|
||||
history = await _chat_history()
|
||||
except Exception:
|
||||
log.exception("Chat history okunamadı")
|
||||
history = []
|
||||
await ws.send_text(json.dumps({"type": "chat_history", "channel": "global", "messages": history}))
|
||||
|
||||
await set_online(char.id, me)
|
||||
await publish_event({"type": "player_joined", **me})
|
||||
|
||||
last_chat = 0.0
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
msg_type = msg.get("type")
|
||||
if msg_type == "ping":
|
||||
# TTL yenile; key düştüyse (worker restart vs.) tekrar yaz
|
||||
refreshed = await redis_client.expire(ONLINE_KEY_PREFIX + str(char.id), ONLINE_TTL)
|
||||
if not refreshed:
|
||||
await set_online(char.id, me)
|
||||
await ws.send_text('{"type": "pong"}')
|
||||
elif msg_type == "chat":
|
||||
now_mono = time.monotonic()
|
||||
if now_mono - last_chat < CHAT_MIN_INTERVAL:
|
||||
continue
|
||||
chat_text = str(msg.get("text", "")).strip()[:CHAT_MAX_LEN]
|
||||
if not chat_text:
|
||||
continue
|
||||
last_chat = now_mono
|
||||
channel = "local" if msg.get("channel") == "local" else "global"
|
||||
cur_loc = hub.locs.get(char.id)
|
||||
if channel == "local" and not cur_loc:
|
||||
continue
|
||||
channel_key = f"loc:{cur_loc}" if channel == "local" else CHAT_CHANNEL
|
||||
try:
|
||||
await _save_chat(char.id, chat_text, channel_key)
|
||||
except Exception:
|
||||
log.exception("Chat kaydedilemedi")
|
||||
continue
|
||||
event = {
|
||||
"type": "chat",
|
||||
"channel": channel,
|
||||
"char_id": char.id,
|
||||
"name": char.name,
|
||||
"text": chat_text,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if channel == "local":
|
||||
event["location_code"] = cur_loc
|
||||
await publish_event(event)
|
||||
elif msg_type == "get_local_history":
|
||||
cur_loc = hub.locs.get(char.id)
|
||||
messages: list[dict] = []
|
||||
if cur_loc:
|
||||
try:
|
||||
messages = await _chat_history(f"loc:{cur_loc}")
|
||||
except Exception:
|
||||
log.exception("Yerel chat history okunamadı")
|
||||
await ws.send_text(json.dumps(
|
||||
{"type": "chat_history", "channel": "local", "messages": messages}))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("WS döngü hatası (char_id=%s)", char.id)
|
||||
finally:
|
||||
hub.local.pop(char.id, None)
|
||||
hub.locs.pop(char.id, None)
|
||||
try:
|
||||
await redis_client.delete(ONLINE_KEY_PREFIX + str(char.id))
|
||||
except Exception:
|
||||
pass
|
||||
await publish_event({"type": "player_left", "char_id": char.id})
|
||||
Loading…
Add table
Add a link
Reference in a new issue