İlk commit: Stepstead backend (FastAPI)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jts 2026-07-11 11:30:24 +03:00
commit ea049b8ccd
45 changed files with 6751 additions and 0 deletions

0
routers/__init__.py Normal file
View file

267
routers/admin.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 ı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
View 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 ı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}ı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, "ı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
View 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
View 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
View 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
View 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
View 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})