İ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

251
routers/craft.py Normal file
View file

@ -0,0 +1,251 @@
"""
Crafting: tarifleri listele, üret.
"""
from datetime import datetime, timezone
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 game_logic import character_level_from_total_xp, skill_level_from_xp
from models import Character, Skill, User, get_db
router = APIRouter(prefix="/craft", tags=["craft"])
class IngredientOut(BaseModel):
item_code: str
item_name: str
qty: int
have: int
class RecipeOut(BaseModel):
id: int
code: str
name: str
output_item_code: str
output_item_name: str
output_qty: int
skill: str
required_level: int
xp_reward: int
step_cost: int
ingredients: list[IngredientOut]
unlocked: bool
craftable: bool
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 _inventory_map(db: AsyncSession, char_id: int) -> dict[str, int]:
rows = (await db.execute(text("""
SELECT i.code, SUM(inv.qty)::int AS qty
FROM inventory inv JOIN items i ON i.id = inv.item_id
WHERE inv.character_id = :cid GROUP BY i.code
"""), {"cid": char_id})).all()
return {r[0]: r[1] for r in rows}
async def _skill_levels(db: AsyncSession, char_id: int) -> dict[str, int]:
rows = (await db.execute(text(
"SELECT skill_name, level FROM skills WHERE character_id = :cid"
), {"cid": char_id})).all()
return {r[0]: r[1] for r in rows}
@router.get("/recipes", response_model=list[RecipeOut])
async def list_recipes(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
inv = await _inventory_map(db, char.id)
skl = await _skill_levels(db, char.id)
bal = char.step_balance
rows = (await db.execute(text("""
SELECT r.id, r.code, r.name, r.output_item_id, r.output_qty,
r.skill, r.required_level, r.xp_reward, r.step_cost, r.ingredients,
oi.code, oi.name
FROM recipes r JOIN items oi ON oi.id = r.output_item_id
ORDER BY r.skill, r.required_level
"""))).all()
out: list[RecipeOut] = []
for r in rows:
ings: list[IngredientOut] = []
all_ok = True
for ing in r[9]: # JSONB
code = ing["item_code"]
qty = int(ing["qty"])
item_row = (await db.execute(text("SELECT name FROM items WHERE code=:c"), {"c": code})).first()
have = inv.get(code, 0)
if have < qty:
all_ok = False
ings.append(IngredientOut(item_code=code, item_name=item_row[0] if item_row else code, qty=qty, have=have))
unlocked = skl.get(r[5], 1) >= r[6]
affordable_steps = bal >= r[8]
craftable = unlocked and all_ok and affordable_steps
out.append(RecipeOut(
id=r[0], code=r[1], name=r[2],
output_item_code=r[10], output_item_name=r[11], output_qty=r[4],
skill=r[5], required_level=r[6], xp_reward=r[7], step_cost=r[8],
ingredients=ings, unlocked=unlocked, craftable=craftable,
))
return out
class CraftIn(BaseModel):
recipe_code: str
qty: int = Field(1, ge=1, le=50)
class CraftOut(BaseModel):
output_item_code: str
output_item_name: str
output_qty: int
skill: str
xp_gained: int
skill_level: int
skill_level_up: bool
character_level: int
character_level_up: bool
step_balance: int
@router.post("", response_model=CraftOut)
async def craft(c: CraftIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
rcp = (await db.execute(text("""
SELECT id, output_item_id, output_qty, skill, required_level, xp_reward, step_cost, ingredients
FROM recipes WHERE code = :c
"""), {"c": c.recipe_code})).first()
if not rcp:
raise HTTPException(404, "Tarif yok")
skill_name = rcp[3]
required_level = rcp[4]
step_cost_per = rcp[6]
xp_per = rcp[5]
ingredients = rcp[7]
# Skill seviye
skill = (await db.execute(
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == skill_name)
)).scalar_one_or_none()
if skill is None:
skill = Skill(character_id=char.id, skill_name=skill_name, xp=0, level=1)
db.add(skill)
await db.flush()
if skill.level < required_level:
raise HTTPException(400, f"{skill_name} {required_level} gerekli (mevcut {skill.level})")
# Toplam adım
total_step = step_cost_per * c.qty
if char.step_balance < total_step:
raise HTTPException(400, f"Yetersiz adım: {total_step} gerekli, {char.step_balance} var")
# Malzeme yeterli mi
inv = await _inventory_map(db, char.id)
for ing in ingredients:
need = int(ing["qty"]) * c.qty
have = inv.get(ing["item_code"], 0)
if have < need:
raise HTTPException(400, f"{ing['item_code']} yetersiz: {need} gerekli, {have} var")
# Malzemeleri tüket (FIFO slotlardan)
for ing in ingredients:
need = int(ing["qty"]) * c.qty
item_id_row = (await db.execute(text("SELECT id FROM items WHERE code=:c"), {"c": ing["item_code"]})).first()
if not item_id_row:
raise HTTPException(500, f"Item bulunamadı: {ing['item_code']}")
item_id = item_id_row[0]
stacks = (await db.execute(text("""
SELECT id, qty FROM inventory
WHERE character_id = :cid AND item_id = :iid
ORDER BY slot
"""), {"cid": char.id, "iid": item_id})).all()
remaining = need
for st in stacks:
if remaining <= 0:
break
take = min(remaining, st[1])
if take == st[1]:
await db.execute(text("DELETE FROM inventory WHERE id = :id"), {"id": st[0]})
else:
await db.execute(text("UPDATE inventory SET qty = qty - :t WHERE id = :id"), {"t": take, "id": st[0]})
remaining -= take
if remaining > 0:
raise HTTPException(500, "İç tutarsızlık: malzeme eksildi ama bulunamadı")
# Output ekle (stack ya da yeni slot)
out_qty = int(rcp[2]) * c.qty
out_item_id = rcp[1]
out_row = (await db.execute(text("SELECT code, name, stack_max FROM items WHERE id = :i"), {"i": out_item_id})).first()
out_code = out_row[0]
out_name = out_row[1]
stack_max = int(out_row[2])
remaining = out_qty
# Önce mevcut stack'lere ekle (stack_max'a kadar)
existing = (await db.execute(text("""
SELECT id, qty FROM inventory WHERE character_id = :cid AND item_id = :iid ORDER BY slot
"""), {"cid": char.id, "iid": out_item_id})).all()
for ex in existing:
if remaining <= 0:
break
space = stack_max - ex[1]
if space <= 0:
continue
add = min(space, remaining)
await db.execute(text("UPDATE inventory SET qty = qty + :a WHERE id = :id"), {"a": add, "id": ex[0]})
remaining -= add
# Yeni slot(lar)
while remaining > 0:
add = min(stack_max, remaining)
slot_row = (await db.execute(text(
"SELECT COALESCE(MAX(slot), -1) + 1 FROM inventory WHERE character_id = :cid"
), {"cid": char.id})).first()
next_slot = slot_row[0]
await db.execute(text("""
INSERT INTO inventory (character_id, item_id, qty, slot)
VALUES (:cid, :iid, :q, :s)
"""), {"cid": char.id, "iid": out_item_id, "q": add, "s": next_slot})
remaining -= add
# XP + adım
xp_gain = xp_per * c.qty
old_sl = skill.level
skill.xp += xp_gain
skill.level = skill_level_from_xp(skill.xp)
skill_up = skill.level > old_sl
old_cl = char.level
char.total_xp += xp_gain
char.level = character_level_from_total_xp(char.total_xp)
char_up = char.level > old_cl
char.step_balance -= total_step
char.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return CraftOut(
output_item_code=out_code,
output_item_name=out_name,
output_qty=out_qty,
skill=skill_name,
xp_gained=xp_gain,
skill_level=skill.level,
skill_level_up=skill_up,
character_level=char.level,
character_level_up=char_up,
step_balance=char.step_balance,
)