"""Сад-сцена (ТЗ 3.13): монеты, размещение растений, декорации, расширения.
Принцип «контент открывается за опыт, покупается за монеты»: опыт (уровень)
открывает виды растений и землю, монеты покупают апгрейды, декорации и
расширения карты. Монеты — валюта сада, отдельная от XP: опыт только растёт,
монеты тратятся.
"""
from typing import Any
from sqlalchemy import func, 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 клеток и к ширине, и к высоте; старт 32×20.
BASE_COLS = 32
BASE_ROWS = 20
EXPANSION_PERIMETER = 8
EXPANSIONS: dict[str, dict[str, int]] = {
"expansion_1": {"level": 3, "cost": 100},
"expansion_2": {"level": 5, "cost": 250},
"expansion_3": {"level": 8, "cost": 500},
"expansion_4": {"level": 9, "cost": 750},
"expansion_5": {"level": 11, "cost": 1000},
}
# Виды растений: открываются уровнем («контент за опыт»); 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) -> int:
total = db.scalar(select(func.coalesce(func.sum(CoinEvent.amount), 0)))
return int(total or 0)
def expansions_bought(db: Session) -> int:
return int(
db.scalar(
select(func.count())
.select_from(CoinEvent)
.where(CoinEvent.source == "shop", CoinEvent.item_key.like("expansion_%"))
)
or 0
)
def grid_size(db: Session) -> dict[str, int]:
extra = expansions_bought(db) * EXPANSION_PERIMETER
return {"cols": BASE_COLS + extra, "rows": BASE_ROWS + extra}
def next_expansion(db: Session) -> dict[str, Any] | None:
"""Следующее доступное расширение (ещё не купленное)."""
bought = expansions_bought(db)
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) -> GardenItem:
"""Купленное семя: растение-росток в инвентаре (x/y NULL), пользователь
сам перетаскивает его на карту. Размещённое семя обратно убрать нельзя."""
item = GardenItem(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, event: XpEvent) -> int:
"""Уровень садовника на момент события XP — по сумме XP событий до него."""
xp_up_to = db.scalar(
select(func.coalesce(func.sum(XpEvent.amount), 0)).where(XpEvent.id <= event.id)
)
return level_from_xp(int(xp_up_to or 0))
def ensure_garden(db: Session) -> None:
"""Ленивое обслуживание сада: бонусы уровней + авторазмещение новых растений.
Гонка параллельных GET /api/garden: оба читают «ничего не выдано» и пишут
одно и то же. Работаем в savepoint — при нарушении уникальности (индексы
uq_coin_level / (kind, ref_id)) свой недописанный кусок откатываем и
выходим: сад уже обслужил параллельный запрос, его данные закоммичены.
"""
try:
_ensure_garden_locked(db)
except IntegrityError:
db.rollback()
def _ensure_garden_locked(db: Session) -> None:
# 1) Бонусы за достигнутые уровни (задним числом — для старых уровней)
reached = level_from_xp(total_xp(db))
granted = set(
db.scalars(
select(CoinEvent.level).where(
CoinEvent.source == "level", CoinEvent.level.is_not(None)
)
).all()
)
for level in range(2, reached + 1):
if level not in granted:
db.add(CoinEvent(source="level", level=level, amount=level_bonus_coins(level)))
# 2) Растения для закрытых задач без размещённого элемента (события
# создания kind='create_*' растения не дают — только закрытия)
placed_refs = set(
db.scalars(
select(GardenItem.ref_id).where(
GardenItem.kind == "plant", GardenItem.ref_id.is_not(None)
)
).all()
)
pending = [
e
for e in db.scalars(
select(XpEvent).where(XpEvent.kind == "task").order_by(XpEvent.id)
).all()
if e.id not in placed_refs
]
if pending:
size = grid_size(db)
# Неразмещенные декорации (в инвентаре) ячейки не занимают
occupied = {
(int(x), int(y))
for x, y in db.execute(
select(GardenItem.x, GardenItem.y).where(
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, event), event.task_id or 0, event.rarity
)
x, y = spiral_position(occupied, size["cols"], size["rows"])
occupied.add((x, y))
db.add(GardenItem(kind="plant", ref_id=event.id, item_key=species, x=x, y=y))
db.flush()
def can_afford(db: Session, cost: int) -> bool:
return balance(db) >= cost
def spend(db: Session, cost: int, item_key: str) -> None:
db.add(CoinEvent(source="shop", amount=-cost, item_key=item_key))