Newer
Older
gnexus-tasks / backend / app / services / garden.py
"""Сад-сцена (ТЗ 3.13): монеты, размещение растений, декорации, расширения.

Принцип «контент открывается за опыт, покупается за монеты»: опыт (уровень)
открывает виды растений и землю, монеты покупают апгрейды, декорации и
расширения карты. Монеты — валюта сада, отдельная от XP: опыт только растёт,
монеты тратятся.
"""

from typing import Any

from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.models import CoinEvent, GardenItem, XpEvent
from app.services.xp import level_from_xp, total_xp

# --- Экономика ---

# Монеты за задачу: floor(xp / 2)
COINS_PER_XP = 2

# Бонус за достижение уровня L (одним событием при пересечении границы)
LEVEL_BONUS = 25


def coins_for_xp(xp: int) -> int:
    return xp // COINS_PER_XP


def level_bonus_coins(level: int) -> int:
    return LEVEL_BONUS * level


# Апгрейд растения по стадиям (0→1, 1→2), цена по редкости
UPGRADE_COSTS: dict[str, tuple[int, int]] = {
    "common": (20, 50),
    "rare": (40, 100),
    "epic": (80, 200),
}
MAX_STAGE = 2

# Каталог декораций: ключ -> (цена, уникальная). Только косметика — без бонусов.
DECORATIONS: dict[str, dict[str, Any]] = {
    "fence": {"cost": 30, "unique": False},
    "bush": {"cost": 25, "unique": False},
    "flowerbed": {"cost": 40, "unique": False},
    "lantern": {"cost": 80, "unique": True},
    "bench": {"cost": 90, "unique": True},
    "birdbath": {"cost": 120, "unique": True},
    "flag": {"cost": 150, "unique": True},
    "birdhouse": {"cost": 180, "unique": True},
    "gem": {"cost": 350, "unique": True},
    "pond": {"cost": 250, "unique": True},
    "greenhouse": {"cost": 600, "unique": True},
    "gazebo": {"cost": 400, "unique": True},
}

# Расширения карты: ключ -> (уровень открытия, цена). Каждое добавляет
# EXPANSION_PERIMETER клеток и к ширине, и к высоте.
# ТЗ 0.37: старт компактный 24×12 — прежний 32×20 заполнялся слишком долго
# и расширение не ощущалось. Первое расширение (ур. 3) возвращает размер
# прежнего старта 32×20, второе (ур. 12) — до 40×28.
BASE_COLS = 24
BASE_ROWS = 12
EXPANSION_PERIMETER = 8
EXPANSIONS: dict[str, dict[str, int]] = {
    "expansion_1": {"level": 3, "cost": 100},
    "expansion_2": {"level": 12, "cost": 250},
}

# Виды растений: открываются уровнем («контент за опыт»); premium — только
# для rare/epic закрытий («элитные виды приживаются у опытного садовника»).
# Ключ = phosphor-иконка кита (проверено по icons.css).
SPECIES: list[dict[str, Any]] = [
    {"key": "ph-flower", "level": 1, "premium": False},
    {"key": "ph-leaf", "level": 1, "premium": False},
    {"key": "ph-tree-evergreen", "level": 1, "premium": False},
    {"key": "ph-flower-lotus", "level": 2, "premium": False},
    {"key": "ph-cactus", "level": 4, "premium": True},
    {"key": "ph-tree", "level": 6, "premium": False},
    {"key": "ph-drop", "level": 7, "premium": False},
    {"key": "ph-sun", "level": 8, "premium": False},
    {"key": "ph-tree-structure", "level": 9, "premium": False},
    {"key": "ph-butterfly", "level": 10, "premium": False},
    {"key": "ph-campfire", "level": 11, "premium": True},
    {"key": "ph-sparkle", "level": 12, "premium": True},
    {"key": "ph-fire", "level": 13, "premium": True},
]

# Семена растений в маркете: вид -> цена. Покупка сразу сажает растение
# (стадия 0) на свободную клетку по спирали; доступность — по уровню (SPECIES).
# Вид из семени — обычной редкости; редкие/эпик рождаются только из задач.
SEED_COSTS: dict[str, int] = {
    "ph-flower": 30,
    "ph-leaf": 40,
    "ph-tree-evergreen": 60,
    "ph-flower-lotus": 90,
    "ph-cactus": 150,
    "ph-tree": 200,
    "ph-drop": 250,
    "ph-sun": 300,
    "ph-tree-structure": 400,
    "ph-butterfly": 500,
    "ph-campfire": 700,
    "ph-sparkle": 900,
    "ph-fire": 1200,
}

# Домик в центре сетки: footprint, который не занимают растения (ячейки)
HOUSE_COLS = 4
HOUSE_ROWS = 3

# Спираль авторазмещения: снаружи от footprint домика
SPIRAL_START_RADIUS = 3


def balance(db: Session, user_id: str) -> int:
    total = db.scalar(
        select(func.coalesce(func.sum(CoinEvent.amount), 0)).where(CoinEvent.user_id == user_id)
    )
    return int(total or 0)


def expansions_bought(db: Session, user_id: str) -> int:
    return int(
        db.scalar(
            select(func.count())
            .select_from(CoinEvent)
            .where(
                CoinEvent.user_id == user_id,
                CoinEvent.source == "shop",
                CoinEvent.item_key.like("expansion_%"),
            )
        )
        or 0
    )


def grid_size(db: Session, user_id: str) -> dict[str, int]:
    extra = expansions_bought(db, user_id) * EXPANSION_PERIMETER
    return {"cols": BASE_COLS + extra, "rows": BASE_ROWS + extra}


def reposition_out_of_bounds(db: Session, user_id: str) -> int:
    """Предметы за границей текущей сетки — переставить по спирали к дому.

    Сад стал компактнее (ТЗ 0.37): у существующих садов часть растений/декора
    оказалась «за забором». Переставляем при первом заходе в сад — ничего
    не теряется. Идемпотентно: второй вызов ничего не находит.
    """
    size = grid_size(db, user_id)
    cols, rows = size["cols"], size["rows"]
    misplaced = list(
        db.scalars(
            select(GardenItem).where(
                GardenItem.user_id == user_id,
                GardenItem.x.is_not(None),
                GardenItem.y.is_not(None),
                or_(GardenItem.x >= cols, GardenItem.y >= rows),
            )
        )
    )
    if not misplaced:
        return 0
    # старые места смещённых вне сетки — спираль их и так не вернёт
    occupied = {
        (int(x), int(y))
        for x, y in db.execute(
            select(GardenItem.x, GardenItem.y).where(
                GardenItem.user_id == user_id,
                GardenItem.x.is_not(None),
                GardenItem.y.is_not(None),
            )
        ).all()
    } | house_footprint(cols, rows)
    for item in misplaced:
        x, y = spiral_position(occupied, cols, rows)
        item.x, item.y = x, y
        occupied.add((x, y))
    db.flush()
    return len(misplaced)


def next_expansion(db: Session, user_id: str) -> dict[str, Any] | None:
    """Следующее доступное расширение (ещё не купленное)."""
    bought = expansions_bought(db, user_id)
    keys = sorted(EXPANSIONS)
    if bought >= len(keys):
        return None
    key = keys[bought]
    return {"key": key, **EXPANSIONS[key]}


def species_catalog(level: int) -> list[dict[str, Any]]:
    """Каталог видов с пометкой «открыт на этом уровне» и ценой семени."""
    return [
        {**s, "unlocked": level >= s["level"], "seed_cost": SEED_COSTS[s["key"]]} for s in SPECIES
    ]


def make_seed_plant(db: Session, species: str, user_id: str) -> GardenItem:
    """Купленное семя: растение-росток в инвентаре (x/y NULL), пользователь
    сам перетаскивает его на карту. Размещённое семя обратно убрать нельзя."""
    item = GardenItem(
        user_id=user_id, kind="plant", ref_id=None, item_key=species, x=None, y=None, stage=0
    )
    db.add(item)
    return item


def species_for(level: int, task_id: int, rarity: str) -> str:
    """Вид нового растения: открытые уровнем виды, premium — только rare/epic.

    Детерминированно от task_id — повторный вызов даёт тот же вид.
    """
    pool = [
        s["key"]
        for s in SPECIES
        if level >= s["level"] and (not s["premium"] or rarity in ("rare", "epic"))
    ]
    return str(pool[task_id % len(pool)])


def house_footprint(cols: int, rows: int) -> set[tuple[int, int]]:
    cx = cols // 2 - HOUSE_COLS // 2
    cy = rows // 2 - HOUSE_ROWS // 2
    return {(x, y) for x in range(cx, cx + HOUSE_COLS) for y in range(cy, cy + HOUSE_ROWS)}


def spiral_position(occupied: set[tuple[int, int]], cols: int, rows: int) -> tuple[int, int]:
    """Следующая свободная ячейка по спирали вокруг домика в центре."""
    cx, cy = cols // 2, rows // 2
    radius = SPIRAL_START_RADIUS
    while radius < max(cols, rows):
        for dx in range(-radius, radius + 1):
            for dy in range(-radius, radius + 1):
                if max(abs(dx), abs(dy)) != radius:
                    continue
                x, y = cx + dx, cy + dy
                if 0 <= x < cols and 0 <= y < rows and (x, y) not in occupied:
                    return (x, y)
        radius += 1
    # сетка кончилась — первая свободная ячейка по порядку
    for y in range(rows):
        for x in range(cols):
            if (x, y) not in occupied:
                return (x, y)
    return (0, 0)


def _level_at_event(db: Session, user_id: str, event: XpEvent) -> int:
    """Уровень садовника на момент события XP — по сумме его XP событий до него."""
    xp_up_to = db.scalar(
        select(func.coalesce(func.sum(XpEvent.amount), 0)).where(
            XpEvent.user_id == user_id, XpEvent.id <= event.id
        )
    )
    return level_from_xp(int(xp_up_to or 0))


def ensure_garden(db: Session, user_id: str) -> None:
    """Ленивое обслуживание сада: бонусы уровней + авторазмещение новых растений.

    Гонка параллельных GET /api/garden: оба читают «ничего не выдано» и пишут
    одно и то же. Работаем в savepoint — при нарушении уникальности (индексы
    uq_coin_level / (kind, ref_id)) свой недописанный кусок откатываем и
    выходим: сад уже обслужил параллельный запрос, его данные закоммичены.
    """
    try:
        _ensure_garden_locked(db, user_id)
    except IntegrityError:
        db.rollback()


def _ensure_garden_locked(db: Session, user_id: str) -> None:
    # 1) Бонусы за достигнутые уровни (задним числом — для старых уровней)
    reached = level_from_xp(total_xp(db, user_id))
    granted = set(
        db.scalars(
            select(CoinEvent.level).where(
                CoinEvent.user_id == user_id,
                CoinEvent.source == "level",
                CoinEvent.level.is_not(None),
            )
        ).all()
    )
    for level in range(2, reached + 1):
        if level not in granted:
            db.add(
                CoinEvent(
                    user_id=user_id, source="level", level=level, amount=level_bonus_coins(level)
                )
            )

    # 2) Растения для закрытых задач без размещённого элемента (события
    #    создания kind='create_*' растения не дают — только закрытия)
    placed_refs = set(
        db.scalars(
            select(GardenItem.ref_id).where(
                GardenItem.user_id == user_id,
                GardenItem.kind == "plant",
                GardenItem.ref_id.is_not(None),
            )
        ).all()
    )
    pending = [
        e
        for e in db.scalars(
            select(XpEvent)
            .where(XpEvent.user_id == user_id, XpEvent.kind == "task")
            .order_by(XpEvent.id)
        ).all()
        if e.id not in placed_refs
    ]
    if pending:
        size = grid_size(db, user_id)
        # Неразмещенные декорации (в инвентаре) ячейки не занимают
        occupied = {
            (int(x), int(y))
            for x, y in db.execute(
                select(GardenItem.x, GardenItem.y).where(
                    GardenItem.user_id == user_id,
                    GardenItem.x.is_not(None),
                    GardenItem.y.is_not(None),
                )
            ).all()
        } | house_footprint(size["cols"], size["rows"])
        for event in pending:
            # вид и уровень — на момент события, чтобы старые закрытия
            # получили вид из пула своего времени
            species = species_for(
                _level_at_event(db, user_id, event), event.task_id or 0, event.rarity
            )
            x, y = spiral_position(occupied, size["cols"], size["rows"])
            occupied.add((x, y))
            db.add(
                GardenItem(
                    user_id=user_id, kind="plant", ref_id=event.id, item_key=species, x=x, y=y
                )
            )
    db.flush()


def can_afford(db: Session, user_id: str, cost: int) -> bool:
    return balance(db, user_id) >= cost


def spend(db: Session, user_id: str, cost: int, item_key: str) -> None:
    db.add(CoinEvent(user_id=user_id, source="shop", amount=-cost, item_key=item_key))