"""API сада (ТЗ 3.13): сцена, монеты, магазин декораций и расширений."""
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from app.dependencies import DbDep, UserDep
from app.models import GardenItem, Task, XpEvent
from app.services import garden
from app.services.garden import (
DECORATIONS,
EXPANSIONS,
MAX_STAGE,
UPGRADE_COSTS,
balance,
ensure_garden,
grid_size,
house_footprint,
next_expansion,
species_catalog,
spiral_position,
)
from app.services.xp import level_from_xp, total_xp
router = APIRouter(prefix="/api/garden", tags=["garden"])
class MoveIn(BaseModel):
x: int
y: int
class BuyIn(BaseModel):
item_key: str
def _state(db: Any) -> dict[str, Any]:
"""Полное состояние сцены после ленивого обслуживания."""
ensure_garden(db)
level = level_from_xp(total_xp(db))
size = grid_size(db)
# Растения + их события (редкость, задача) одним запросом
rows = db.execute(
select(GardenItem, XpEvent, Task)
.join(XpEvent, GardenItem.ref_id == XpEvent.id, isouter=True)
.join(Task, XpEvent.task_id == Task.id, isouter=True)
.where(GardenItem.kind == "plant")
.order_by(GardenItem.id)
).all()
items: list[dict[str, Any]] = []
for item, event, task in rows:
items.append(
{
"id": item.id,
"kind": "plant",
"ref_id": item.ref_id,
"item_key": item.item_key,
"x": item.x,
"y": item.y,
"stage": item.stage,
"rarity": event.rarity if event else "common",
"task_title": task.title if task else None,
"done_at": event.created_at.isoformat() if event else None,
}
)
decorations = db.scalars(
select(GardenItem).where(GardenItem.kind == "decoration").order_by(GardenItem.id)
).all()
items += [
{
"id": d.id,
"kind": "decoration",
"ref_id": None,
"item_key": d.item_key,
"x": d.x,
"y": d.y,
"stage": 0,
"rarity": None,
"task_title": None,
"done_at": None,
}
for d in decorations
]
expansion = next_expansion(db)
return {
"balance": balance(db),
"level": level,
"grid": size,
"items": items,
"decorations": [
{"key": key, "cost": spec["cost"], "unique": spec["unique"]}
for key, spec in DECORATIONS.items()
],
"next_expansion": expansion,
"expansions_bought": garden.expansions_bought(db),
"species": species_catalog(level),
"upgrade_costs": UPGRADE_COSTS,
}
@router.get("")
async def get_garden(db: DbDep, user: UserDep) -> dict[str, Any]:
"""Состояние сцены: баланс, элементы, каталоги, сетка."""
return _state(db)
@router.patch("/items/{item_id}")
async def move_item(item_id: int, move: MoveIn, db: DbDep, user: UserDep) -> dict[str, Any]:
item = db.get(GardenItem, item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
size = grid_size(db)
if not (0 <= move.x < size["cols"] and 0 <= move.y < size["rows"]):
raise HTTPException(status_code=400, detail="Position outside the garden")
if item.kind == "plant" and (move.x, move.y) in house_footprint(
size["cols"], size["rows"]
):
raise HTTPException(status_code=400, detail="Cannot place on the house")
item.x, item.y = move.x, move.y
db.flush()
return {"ok": True}
@router.post("/plants/{item_id}/upgrade")
async def upgrade_plant(item_id: int, db: DbDep, user: UserDep) -> dict[str, Any]:
item = db.get(GardenItem, item_id)
if item is None or item.kind != "plant":
raise HTTPException(status_code=404, detail="Plant not found")
if item.stage >= MAX_STAGE:
raise HTTPException(status_code=400, detail="Plant is fully grown")
event = db.get(XpEvent, item.ref_id) if item.ref_id else None
rarity = event.rarity if event else "common"
cost = UPGRADE_COSTS[rarity][item.stage]
if not garden.can_afford(db, cost):
raise HTTPException(
status_code=402, detail=f"Not enough coins: need {cost}"
)
garden.spend(db, cost, f"upgrade:{item.item_key}")
item.stage += 1
db.flush()
return {"ok": True, "stage": item.stage, "cost": cost, "balance": balance(db)}
@router.post("/shop/buy")
async def shop_buy(buy: BuyIn, db: DbDep, user: UserDep) -> dict[str, Any]:
item_key = buy.item_key
level = level_from_xp(total_xp(db))
if item_key in EXPANSIONS:
expansion = next_expansion(db)
if expansion is None or expansion["key"] != item_key:
raise HTTPException(status_code=409, detail="Expansion already bought")
spec = EXPANSIONS[item_key]
if level < spec["level"]:
raise HTTPException(
status_code=403, detail=f"Unlocks at level {spec['level']}"
)
if not garden.can_afford(db, spec["cost"]):
raise HTTPException(status_code=402, detail=f"Not enough coins: need {spec['cost']}")
garden.spend(db, spec["cost"], item_key)
db.flush()
return {"ok": True, "grid": grid_size(db), "balance": balance(db)}
if item_key in DECORATIONS:
spec = DECORATIONS[item_key]
owned = db.scalar(
select(GardenItem).where(
GardenItem.kind == "decoration", GardenItem.item_key == item_key
)
)
if spec["unique"] and owned is not None:
raise HTTPException(status_code=409, detail="Already bought")
if not garden.can_afford(db, spec["cost"]):
raise HTTPException(status_code=402, detail=f"Not enough coins: need {spec['cost']}")
size = grid_size(db)
occupied = {
(int(x), int(y)) for x, y in db.execute(select(GardenItem.x, GardenItem.y)).all()
} | house_footprint(size["cols"], size["rows"])
x, y = spiral_position(occupied, size["cols"], size["rows"])
item = GardenItem(kind="decoration", item_key=item_key, x=x, y=y)
db.add(item)
garden.spend(db, spec["cost"], item_key)
db.flush()
return {
"ok": True,
"item": {"id": item.id, "item_key": item.item_key, "x": item.x, "y": item.y},
"balance": balance(db),
}
raise HTTPException(status_code=400, detail=f"Unknown item: {item_key}")