271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""
|
||
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}
|