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