stepstead-backend/routers/market.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

505 lines
19 KiB
Python
Raw 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.

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