stepstead-backend/routers/travel.py
jts ea049b8ccd İlk commit: Stepstead backend (FastAPI)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:30:24 +03:00

536 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,
)