""" NPC yönetimi — oyun için oluşturulan NPC listesi ("npc" izni). Konum = map editöründe çizilen alan (region) veya pin (poi) → map_tags. Taraf (stance): friendly (dost) / hostile (düşman) / neutral (tarafsız). Bağlar: quest tek vericiye bağlı (exclusive); item çoktan-çoğa (npc_items). WAF nedeniyle sadece GET/POST. GET /npcs → liste (yer + taraf + bağlı quest/item) GET /npcs/locations → yer dropdown verisi GET /npcs/catalog → quest + item kataloğu (çoktan seçmeli için) POST /npcs → yeni NPC POST /npcs/{id} → güncelle POST /npcs/{id}/delete → sil POST /npcs/{id}/quests → bu NPC'nin verdiği görevleri ayarla (exclusive) POST /npcs/{id}/items → bu NPC'nin sattığı itemları ayarla (replace) """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from deps import require_perm from models import User, get_db router = APIRouter(prefix="/npcs", tags=["npcs"]) STANCES = {"friendly", "hostile", "neutral"} class QuestMini(BaseModel): id: int title: str class ItemMini(BaseModel): id: int code: str | None name: str type: str | None rarity: str | None class NpcIn(BaseModel): name: str = Field(min_length=1, max_length=80) role: str | None = Field(default=None, max_length=80) map_tag_id: int | None = None stance: str = "neutral" description: str | None = Field(default=None, max_length=20000) notes: str | None = Field(default=None, max_length=20000) class NpcOut(BaseModel): id: int name: str role: str | None map_tag_id: int | None location_name: str | None location_kind: str | None location_category: str | None stance: str description: str | None notes: str | None quests: list[QuestMini] sells: list[ItemMini] updated_at: str class LocationRow(BaseModel): id: int name: str kind: str category: str | None continent: str | None class Catalog(BaseModel): quests: list[dict] # {id,title,giver_npc_id} items: list[ItemMini] class IdList(BaseModel): ids: list[int] = Field(default_factory=list) async def _check_location(db: AsyncSession, map_tag_id: int | None) -> None: if map_tag_id is None: return ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"), {"id": map_tag_id})).scalar() if not ok: raise HTTPException(400, "Yer yok (map editöründe çizilen alan/pin olmalı)") def _check_stance(stance: str) -> None: if stance not in STANCES: raise HTTPException(400, "Geçersiz taraf (friendly/hostile/neutral)") _SEL = ( "SELECT n.id,n.name,n.role,n.map_tag_id,t.name AS location_name,t.kind AS location_kind," "t.category AS location_category,n.stance,n.description,n.notes,n.updated_at " "FROM npcs n LEFT JOIN map_tags t ON t.id=n.map_tag_id " ) async def _links(db: AsyncSession, npc_ids: list[int]) -> tuple[dict, dict]: """Verilen npc'ler için {npc_id: [quests]} ve {npc_id: [items]} döndür.""" quests: dict[int, list] = {i: [] for i in npc_ids} items: dict[int, list] = {i: [] for i in npc_ids} if not npc_ids: return quests, items qrows = (await db.execute(text( "SELECT id,title,giver_npc_id FROM quests WHERE giver_npc_id = ANY(:ids)" ), {"ids": npc_ids})).mappings().all() for r in qrows: quests[r["giver_npc_id"]].append(QuestMini(id=r["id"], title=r["title"])) irows = (await db.execute(text( "SELECT ni.npc_id, i.id,i.code,i.name,i.type,i.rarity " "FROM npc_items ni JOIN items i ON i.id=ni.item_id " "WHERE ni.npc_id = ANY(:ids) ORDER BY i.type,i.name" ), {"ids": npc_ids})).mappings().all() for r in irows: items[r["npc_id"]].append(ItemMini(id=r["id"], code=r["code"], name=r["name"], type=r["type"], rarity=r["rarity"])) return quests, items async def _one(db: AsyncSession, npc_id: int) -> NpcOut: got = (await db.execute(text(_SEL + "WHERE n.id=:id"), {"id": npc_id})).mappings().one() q, it = await _links(db, [npc_id]) d = dict(got) d["updated_at"] = d["updated_at"].isoformat() d["quests"] = q[npc_id] d["sells"] = it[npc_id] return NpcOut(**d) @router.get("", response_model=list[NpcOut]) async def list_npcs(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): rows = (await db.execute(text(_SEL + "ORDER BY t.name NULLS LAST, n.name"))).mappings().all() ids = [r["id"] for r in rows] q, it = await _links(db, ids) out = [] for r in rows: d = dict(r) d["updated_at"] = d["updated_at"].isoformat() d["quests"] = q[r["id"]] d["sells"] = it[r["id"]] out.append(NpcOut(**d)) return out @router.get("/locations", response_model=list[LocationRow]) async def npc_locations(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): rows = (await db.execute(text( "SELECT id,name,kind,category,continent FROM map_tags WHERE kind IN ('region','poi') " "ORDER BY kind DESC, continent NULLS LAST, name" ))).mappings().all() return [LocationRow(**dict(r)) for r in rows] @router.get("/catalog", response_model=Catalog) async def catalog(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): qrows = (await db.execute(text( "SELECT id,title,giver_npc_id FROM quests ORDER BY title" ))).mappings().all() irows = (await db.execute(text( "SELECT id,code,name,type,rarity FROM items ORDER BY type,name" ))).mappings().all() return Catalog( quests=[dict(r) for r in qrows], items=[ItemMini(**dict(r)) for r in irows], ) @router.post("", response_model=NpcOut) async def create_npc(n: NpcIn, user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): await _check_location(db, n.map_tag_id) _check_stance(n.stance) row = (await db.execute(text( "INSERT INTO npcs(name,role,map_tag_id,stance,description,notes) " "VALUES(:name,:role,:tag,:stance,:descr,:notes) RETURNING id" ), {"name": n.name, "role": n.role, "tag": n.map_tag_id, "stance": n.stance, "descr": n.description, "notes": n.notes})).scalar_one() await db.commit() return await _one(db, row) @router.post("/{npc_id}", response_model=NpcOut) async def update_npc(npc_id: int, n: NpcIn, user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): await _check_location(db, n.map_tag_id) _check_stance(n.stance) res = await db.execute(text( "UPDATE npcs SET name=:name,role=:role,map_tag_id=:tag,stance=:stance," "description=:descr,notes=:notes,updated_at=now() WHERE id=:id" ), {"id": npc_id, "name": n.name, "role": n.role, "tag": n.map_tag_id, "stance": n.stance, "descr": n.description, "notes": n.notes}) if res.rowcount == 0: raise HTTPException(404, "NPC yok") await db.commit() return await _one(db, npc_id) @router.post("/{npc_id}/quests", response_model=NpcOut) async def set_quests(npc_id: int, body: IdList, user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): """Bu NPC'nin verdiği görevleri ayarla. Görev tek vericiye bağlıdır (exclusive).""" exists = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": npc_id})).scalar() if not exists: raise HTTPException(404, "NPC yok") ids = list(dict.fromkeys(body.ids)) if ids: # başka NPC'ye bağlı görev çalınamaz taken = (await db.execute(text( "SELECT title FROM quests WHERE id = ANY(:ids) AND giver_npc_id IS NOT NULL " "AND giver_npc_id <> :nid" ), {"ids": ids, "nid": npc_id})).scalars().all() if taken: raise HTTPException(409, f"Bu görev(ler) başka NPC'ye bağlı: {', '.join(taken)}") bad = (await db.execute(text( "SELECT :cnt - count(*) FROM quests WHERE id = ANY(:ids)" ), {"ids": ids, "cnt": len(ids)})).scalar() if bad: raise HTTPException(400, "Geçersiz görev id") # bu npc'nin listeden çıkarılan görevlerini serbest bırak await db.execute(text( "UPDATE quests SET giver_npc_id=NULL WHERE giver_npc_id=:nid " "AND NOT (id = ANY(:ids))" ), {"nid": npc_id, "ids": ids}) # seçilenleri bu npc'ye bağla if ids: await db.execute(text( "UPDATE quests SET giver_npc_id=:nid WHERE id = ANY(:ids)" ), {"nid": npc_id, "ids": ids}) await db.commit() return await _one(db, npc_id) @router.post("/{npc_id}/items", response_model=NpcOut) async def set_items(npc_id: int, body: IdList, user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): """Bu NPC'nin sattığı itemları ayarla (replace). Bir item birden çok NPC'de olabilir.""" exists = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": npc_id})).scalar() if not exists: raise HTTPException(404, "NPC yok") ids = list(dict.fromkeys(body.ids)) if ids: bad = (await db.execute(text( "SELECT :cnt - count(*) FROM items WHERE id = ANY(:ids)" ), {"ids": ids, "cnt": len(ids)})).scalar() if bad: raise HTTPException(400, "Geçersiz item id") await db.execute(text("DELETE FROM npc_items WHERE npc_id=:nid"), {"nid": npc_id}) if ids: await db.execute(text( "INSERT INTO npc_items(npc_id,item_id) " "SELECT :nid, x FROM unnest(cast(:ids AS bigint[])) AS x ON CONFLICT DO NOTHING" ), {"nid": npc_id, "ids": ids}) await db.commit() return await _one(db, npc_id) @router.post("/{npc_id}/delete") async def delete_npc(npc_id: int, user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)): res = await db.execute(text("DELETE FROM npcs WHERE id=:id"), {"id": npc_id}) await db.commit() if res.rowcount == 0: raise HTTPException(404, "NPC yok") return {"deleted": npc_id}