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

271 lines
10 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.

"""
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}