"""Сад-сцена (ТЗ 3.13): монеты, размещение растений, декорации, расширения.
Принцип «контент открывается за опыт, покупается за монеты»: опыт (уровень)
открывает виды растений и землю, монеты покупают апгрейды, декорации и
расширения карты. Монеты — валюта сада, отдельная от XP: опыт только растёт,
монеты тратятся.
"""
from typing import Any
from sqlalchemy import func, select
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},
"flowerbed": {"cost": 40, "unique": False},
"lantern": {"cost": 80, "unique": True},
"bench": {"cost": 90, "unique": True},
"birdbath": {"cost": 120, "unique": True},
"pond": {"cost": 250, "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},
}
# Виды растений: открываются уровнем («контент за опыт»); 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},
]
# Домик в центре сетки: 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"]} for s in SPECIES]
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:
"""Ленивое обслуживание сада: бонусы уровней + авторазмещение новых растений."""
# 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) Растения для событий XP без размещённого элемента
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).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)).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))