İlk commit: Stepstead backend (FastAPI)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
ea049b8ccd
45 changed files with 6751 additions and 0 deletions
194
routers/world.py
Normal file
194
routers/world.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""
|
||||
World: pedometer adım senkronizasyonu + günlük hedef/streak.
|
||||
|
||||
NOT (2026-07-04): Tile/chunk sistemi kaldırıldı — dünya artık lokasyon-graf
|
||||
(bkz. routers/travel.py: /world/map, /world/travel, /world/gather, /world/event/resolve).
|
||||
"""
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from deps import get_current_user
|
||||
from models import Character, User, get_db
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["world"])
|
||||
|
||||
# Pedometer adım senkronizasyonu (gerçek cihaz)
|
||||
STEP_CAP_PER_MIN = 250 # dakika başı kabul edilen maks adım (koşu dahil)
|
||||
STEP_SYNC_GRACE = 500 # her sync'te elapsed-tavanına eklenen sabit pay
|
||||
STEP_FIRST_SYNC_MAX = 5000 # ilk sync / last_sync_at boşken kabul tavanı
|
||||
STEP_SYNC_MIN_INTERVAL = 10 # iki sync arası min saniye (spam koruması)
|
||||
|
||||
# Günlük hedef + streak
|
||||
DAILY_GOAL = 3000 # günlük gerçek adım hedefi
|
||||
DAILY_BONUS_BASE = 200 # hedef ödülü (adım bakiyesi)
|
||||
DAILY_BONUS_PER_STREAK = 50 # streak günü başına ek (7 günde tavan)
|
||||
DAILY_STREAK_CAP = 7
|
||||
DAILY_GOLD_BONUS = 5 # hedef ödülü (gold)
|
||||
|
||||
|
||||
def _roll_daily(char: Character, today: date) -> None:
|
||||
"""Gün değiştiyse günlük sayacı sıfırla; ödül dünden alınmadıysa streak kırılır."""
|
||||
if char.daily_date != today:
|
||||
char.daily_date = today
|
||||
char.daily_steps = 0
|
||||
if char.last_claim_date is not None and (today - char.last_claim_date).days > 1:
|
||||
char.streak_days = 0
|
||||
|
||||
|
||||
class SyncStepsIn(BaseModel):
|
||||
delta: int = Field(ge=0, le=200000) # son sync'ten beri eklenen adım
|
||||
source: str = Field("unknown", max_length=32)
|
||||
|
||||
|
||||
class SyncStepsOut(BaseModel):
|
||||
step_balance: int
|
||||
step_lifetime: int
|
||||
accepted: int
|
||||
rejected: int
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/sync-steps", response_model=SyncStepsOut)
|
||||
async def sync_steps(d: SyncStepsIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
last = char.last_sync_at
|
||||
reason = None
|
||||
|
||||
# tavan hesabı
|
||||
if last is None:
|
||||
cap = STEP_FIRST_SYNC_MAX
|
||||
else:
|
||||
elapsed = (now - last).total_seconds()
|
||||
if elapsed < STEP_SYNC_MIN_INTERVAL and d.delta > 0:
|
||||
# çok sık sync — yine de kabul et ama küçük tavan uygula
|
||||
elapsed = STEP_SYNC_MIN_INTERVAL
|
||||
cap = int(elapsed / 60.0 * STEP_CAP_PER_MIN) + STEP_SYNC_GRACE
|
||||
|
||||
accepted = min(d.delta, cap)
|
||||
rejected = d.delta - accepted
|
||||
if rejected > 0:
|
||||
reason = "cap_exceeded: delta=%d cap=%d" % (d.delta, cap)
|
||||
|
||||
_roll_daily(char, now.date())
|
||||
if accepted > 0:
|
||||
char.step_balance += accepted
|
||||
char.step_lifetime += accepted
|
||||
char.daily_steps += accepted
|
||||
char.last_sync_at = now
|
||||
|
||||
src = (d.source or "unknown")[:32]
|
||||
if d.delta > 0:
|
||||
await db.execute(text(
|
||||
"INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
|
||||
"VALUES (:cid, :sd, :src, :acc, :rsn)"
|
||||
), {"cid": char.id, "sd": d.delta, "src": src, "acc": rejected == 0, "rsn": reason})
|
||||
|
||||
await db.commit()
|
||||
return SyncStepsOut(
|
||||
step_balance=char.step_balance,
|
||||
step_lifetime=char.step_lifetime,
|
||||
accepted=accepted,
|
||||
rejected=rejected,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
# DEBUG: adım yükleme (pedometer gelene kadar test için)
|
||||
class DebugAddStepsIn(BaseModel):
|
||||
steps: int = Field(gt=0, le=10000)
|
||||
|
||||
|
||||
@router.post("/debug/add-steps", response_model=dict)
|
||||
async def debug_add_steps(d: DebugAddStepsIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
_roll_daily(char, datetime.now(timezone.utc).date())
|
||||
char.step_balance += d.steps
|
||||
char.step_lifetime += d.steps
|
||||
char.daily_steps += d.steps
|
||||
await db.commit()
|
||||
return {"step_balance": char.step_balance, "step_lifetime": char.step_lifetime}
|
||||
|
||||
|
||||
# ---------- Günlük hedef + streak ----------
|
||||
|
||||
class DailyOut(BaseModel):
|
||||
goal: int
|
||||
today_steps: int
|
||||
streak_days: int
|
||||
claimable: bool
|
||||
claimed_today: bool
|
||||
bonus_steps: int # şu anki streak'e göre bugünkü ödül
|
||||
bonus_gold: int
|
||||
|
||||
|
||||
def _bonus_for(streak_after_claim: int) -> int:
|
||||
return DAILY_BONUS_BASE + DAILY_BONUS_PER_STREAK * min(streak_after_claim, DAILY_STREAK_CAP)
|
||||
|
||||
|
||||
def _daily_out(char: Character, today: date) -> DailyOut:
|
||||
claimed_today = char.last_claim_date == today
|
||||
claimable = (not claimed_today) and char.daily_steps >= DAILY_GOAL
|
||||
next_streak = char.streak_days if claimed_today else char.streak_days + 1
|
||||
return DailyOut(
|
||||
goal=DAILY_GOAL, today_steps=char.daily_steps, streak_days=char.streak_days,
|
||||
claimable=claimable, claimed_today=claimed_today,
|
||||
bonus_steps=_bonus_for(next_streak), bonus_gold=DAILY_GOLD_BONUS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyOut)
|
||||
async def daily_status(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
today = datetime.now(timezone.utc).date()
|
||||
_roll_daily(char, today)
|
||||
await db.commit()
|
||||
return _daily_out(char, today)
|
||||
|
||||
|
||||
class DailyClaimOut(BaseModel):
|
||||
streak_days: int
|
||||
bonus_steps: int
|
||||
bonus_gold: int
|
||||
step_balance: int
|
||||
gold: int
|
||||
|
||||
|
||||
@router.post("/daily/claim", response_model=DailyClaimOut)
|
||||
async def daily_claim(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
|
||||
if not char:
|
||||
raise HTTPException(404, "Karakter yok")
|
||||
today = datetime.now(timezone.utc).date()
|
||||
_roll_daily(char, today)
|
||||
if char.last_claim_date == today:
|
||||
raise HTTPException(400, "Bugünkü ödülü zaten aldın")
|
||||
if char.daily_steps < DAILY_GOAL:
|
||||
raise HTTPException(400, f"Hedef dolmadı: {char.daily_steps}/{DAILY_GOAL}")
|
||||
|
||||
char.streak_days += 1
|
||||
char.last_claim_date = today
|
||||
bonus = _bonus_for(char.streak_days)
|
||||
char.step_balance += bonus
|
||||
char.gold += DAILY_GOLD_BONUS
|
||||
await db.execute(text(
|
||||
"INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
|
||||
"VALUES (:cid, :sd, 'daily_bonus', true, :rsn)"
|
||||
), {"cid": char.id, "sd": bonus, "rsn": f"streak={char.streak_days}"})
|
||||
await db.commit()
|
||||
return DailyClaimOut(
|
||||
streak_days=char.streak_days, bonus_steps=bonus, bonus_gold=DAILY_GOLD_BONUS,
|
||||
step_balance=char.step_balance, gold=char.gold,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue