İlk commit: Stepstead backend (FastAPI)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jts 2026-07-11 11:30:24 +03:00
commit ea049b8ccd
45 changed files with 6751 additions and 0 deletions

271
routers/npcs.py Normal file
View file

@ -0,0 +1,271 @@
"""
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}