""" 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 → açı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} açı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, "Açı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)