536 lines
19 KiB
Python
536 lines
19 KiB
Python
"""
|
||
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,
|
||
)
|