diff --git a/backend/alembic/versions/e1f0a2b3c4d5_garden_coins_items.py b/backend/alembic/versions/e1f0a2b3c4d5_garden_coins_items.py new file mode 100644 index 0000000..8ad0a96 --- /dev/null +++ b/backend/alembic/versions/e1f0a2b3c4d5_garden_coins_items.py @@ -0,0 +1,57 @@ +"""Сад-сцена: журнал монет (coin_events) и элементы сцены (garden_items). + +Монеты начисляются за закрытые задачи (floor(xp/2)) задним числом по всем +существующим xp_events; бонусы уровней и траты — в сервисе сада. + +Revision ID: e1f0a2b3c4d5 +Revises: d9e8f7c6b5a4 +Create Date: 2026-09-20 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "e1f0a2b3c4d5" +down_revision: str | Sequence[str] | None = "d9e8f7c6b5a4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "coin_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("task_id", sa.Integer(), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True), + sa.Column("level", sa.Integer(), nullable=True), + sa.Column("source", sa.String(length=16), nullable=False, server_default="task"), + sa.Column("amount", sa.Integer(), nullable=False), + sa.Column("item_key", sa.String(length=50), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_table( + "garden_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("ref_id", sa.Integer(), sa.ForeignKey("xp_events.id", ondelete="CASCADE"), nullable=True), + sa.Column("item_key", sa.String(length=50), nullable=True), + sa.Column("x", sa.Integer(), nullable=False, server_default="0"), + sa.Column("y", sa.Integer(), nullable=False, server_default="0"), + sa.Column("stage", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("kind", "ref_id"), + ) + # Монеты за уже закрытые задачи задним числом + op.get_bind().execute( + sa.text( + "INSERT INTO coin_events (task_id, source, amount, created_at) " + "SELECT task_id, 'task', amount / 2, created_at FROM xp_events" + ) + ) + + +def downgrade() -> None: + op.drop_table("garden_items") + op.drop_table("coin_events") \ No newline at end of file diff --git a/backend/app/api/garden.py b/backend/app/api/garden.py new file mode 100644 index 0000000..fd5feba --- /dev/null +++ b/backend/app/api/garden.py @@ -0,0 +1,194 @@ +"""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}") diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index e2e8f78..5bd53c3 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -6,7 +6,7 @@ from sqlalchemy import select from app.dependencies import DbDep, UserDep -from app.models import Project, Tag, Task, utcnow +from app.models import CoinEvent, Project, Tag, Task, utcnow from app.schemas import ( DEADLINE_PERIODS, RECUR_KINDS, @@ -17,9 +17,10 @@ TaskUpdate, ) from app.services.detailing import apply_proposal, detail_task +from app.services.garden import coins_for_xp, level_bonus_coins from app.services.options import pick_options from app.services.recurrence import spawn_next -from app.services.xp import grant_task_xp +from app.services.xp import grant_task_xp, level_from_xp, total_xp router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -156,6 +157,19 @@ if task.mentally_hard: # «Ментально сложная» — фронт украшает праздник («сила воли») response.headers["X-Mentally-Hard"] = "1" + # Монеты — вторая награда за закрытие (валюта сада, рядом с XP) + old_level = level_from_xp(total_xp(db) - event.amount) + coins = coins_for_xp(event.amount) + db.add(CoinEvent(task_id=task.id, source="task", amount=coins)) + new_level = level_from_xp(total_xp(db)) + if new_level > old_level: + coins += level_bonus_coins(new_level) + db.add( + CoinEvent( + source="level", level=new_level, amount=level_bonus_coins(new_level) + ) + ) + response.headers["X-Earned-Coins"] = str(coins) db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления diff --git a/backend/app/main.py b/backend/app/main.py index df8380d..bc78401 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from starlette.responses import JSONResponse, Response from app import mcp_server -from app.api import attachments, projects, tags, tasks, xp +from app.api import attachments, garden, projects, tags, tasks, xp from app.api.settings import router as settings_router from app.auth.routes import router as auth_router from app.config import get_settings @@ -63,6 +63,7 @@ app.include_router(attachments.router) app.include_router(settings_router) app.include_router(xp.router) +app.include_router(garden.router) # MCP-сервер для ИИ-агентов (M5) app.mount("/mcp", mcp_server.mcp.streamable_http_app()) diff --git a/backend/app/models.py b/backend/app/models.py index 4a23bdd..357b0f8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -257,3 +257,52 @@ # Закрыто из режима «3 вариантов» (ТЗ 3.9) — для счётчика «выбрал и сделал» via_options: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class CoinEvent(Base): + """Событие монет (геймификация, ТЗ 3.13): журнал и заработка, и трат. + + Монеты — валюта сада, отдельная от XP: опыт только растёт, монеты тратятся. + Баланс = сумма amount по всем событиям. Начисление за задачу — один раз + (вместе с XpEvent), бонус за уровень — один раз на уровень (уникальность + в сервисе), траты — отрицательные amount (source='shop'). + """ + + __tablename__ = "coin_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + task_id: Mapped[int | None] = mapped_column( + ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None + ) + # Бонус за уровень (source='level') — какой уровень достигнут + level: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + source: Mapped[str] = mapped_column(String(16), default="task") # task | level | shop + amount: Mapped[int] = mapped_column(Integer) # + заработано / − потрачено + # Ключ покупки (декорация или расширение карты) для трат + item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + +class GardenItem(Base): + """Элемент сцены сада: растение (закрытая задача) или декорация. + + Растение привязано к XpEvent (не к задаче — растение переживает удаление + задачи); вид зафиксирован в item_key при рождении. Позиция — ячейки + мелкой сетки сада (см. services/garden.py). + """ + + __tablename__ = "garden_items" + __table_args__ = (UniqueConstraint("kind", "ref_id"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + kind: Mapped[str] = mapped_column(String(16)) # plant | decoration + ref_id: Mapped[int | None] = mapped_column( + ForeignKey("xp_events.id", ondelete="CASCADE"), nullable=True, default=None + ) + # Для растения — ключ вида (species), для декорации — ключ каталога + item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None) + x: Mapped[int] = mapped_column(Integer, default=0) + y: Mapped[int] = mapped_column(Integer, default=0) + # Стадия роста растения: 0 росток, 1 куст, 2 цветение + stage: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/services/garden.py b/backend/app/services/garden.py new file mode 100644 index 0000000..8a557e5 --- /dev/null +++ b/backend/app/services/garden.py @@ -0,0 +1,224 @@ +"""Сад-сцена (ТЗ 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)) + + diff --git a/backend/tests/test_garden.py b/backend/tests/test_garden.py new file mode 100644 index 0000000..08fbb10 --- /dev/null +++ b/backend/tests/test_garden.py @@ -0,0 +1,194 @@ +"""Сад-сцена (ТЗ 3.13): монеты, магазин, апгрейды, перемещение, авторазмещение.""" + +from typing import Any + +from fastapi.testclient import TestClient + +from app.services.garden import ( + DECORATIONS, + EXPANSIONS, + coins_for_xp, + level_bonus_coins, + spiral_position, +) + + +def _create_task(client: TestClient, **patch: Any) -> int: + resp = client.post("/api/tasks", json={"title": "Задача для сада"}) + task_id = resp.json()["id"] + if patch: + resp2 = client.patch(f"/api/tasks/{task_id}", json=patch) + assert resp2.status_code == 200 + return task_id + + +def _done(client: TestClient, task_id: int) -> dict[str, str]: + resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + assert resp.status_code == 200 + return { + "xp": resp.headers.get("X-Earned-XP", ""), + "coins": resp.headers.get("X-Earned-Coins", ""), + } + + +# --- Монеты за задачу --- + + +def test_coins_on_done_and_idempotent(client: TestClient) -> None: + task_id = _create_task(client) + earned = _done(client, task_id) + assert int(earned["coins"]) == coins_for_xp(int(earned["xp"])) + + # Повторное закрытие не добавляет ни XP, ни монет + earned2 = _done(client, task_id) + assert earned2["xp"] == "" and earned2["coins"] == "" + + state = client.get("/api/garden").json() + assert state["balance"] == coins_for_xp(int(earned["xp"])) + + +def test_level_bonus_coins(client: TestClient) -> None: + # Уровень 2 требует 100 XP: три тяжёлые задачи дают 3×40 = 120 → L2 + for _ in range(3): + task_id = _create_task(client, priority=9, estimated_minutes=480) + _done(client, task_id) + # Монеты: 3 × 20 за задачи + бонус 25×2 = 110 + assert client.get("/api/garden").json()["balance"] == 110 + + +# --- Перемещение --- + + +def test_move_and_validation(client: TestClient) -> None: + task_id = _create_task(client) + _done(client, task_id) + item = next(i for i in client.get("/api/garden").json()["items"] if i["kind"] == "plant") + + resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": 5, "y": 7}) + assert resp.status_code == 200 + state = client.get("/api/garden").json() + moved = next(i for i in state["items"] if i["id"] == item["id"]) + assert (moved["x"], moved["y"]) == (5, 7) + + # Вне сетки — 400 + size = state["grid"] + resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": size["cols"], "y": 0}) + assert resp.status_code == 400 + + +# --- Апгрейд растений --- + + +def test_plant_upgrade_costs_and_402(client: TestClient, monkeypatch: Any) -> None: + # Редкость случайна — фиксируем roll: всегда common + monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99) + task_id = _create_task(client, priority=9, estimated_minutes=480) + _done(client, task_id) # 40 XP → 20 монет + plant = next(i for i in client.get("/api/garden").json()["items"] if i["kind"] == "plant") + assert plant["rarity"] == "common" + + # Первая стадия ровно по карману (20 = 20) + resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade") + assert resp.status_code == 200 + assert resp.json()["stage"] == 1 + assert resp.json()["balance"] == 0 + + # Вторая стадия — не хватает (50 > 0) + resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade") + assert resp.status_code == 402 + assert "50" in resp.json()["detail"] + + for _ in range(10): + _done(client, _create_task(client)) # 10×5 монет + бонус L2 — с запасом + resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade") + assert resp.status_code == 200 + assert resp.json()["stage"] == 2 + + # Больше нельзя — 400 + resp = client.post(f"/api/garden/plants/{plant['id']}/upgrade") + assert resp.status_code == 400 + + +# --- Магазин --- + + +def test_shop_buy_decoration_and_repeatable(client: TestClient, monkeypatch: Any) -> None: + monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99) + _done(client, _create_task(client, priority=9, estimated_minutes=480)) + _done(client, _create_task(client, priority=9, estimated_minutes=480)) # 40 монет + resp = client.post("/api/garden/shop/buy", json={"item_key": "fence"}) + assert resp.status_code == 200 + balance_after = resp.json()["balance"] + assert balance_after == 40 - DECORATIONS["fence"]["cost"] + assert any( + i["kind"] == "decoration" and i["item_key"] == "fence" + for i in client.get("/api/garden").json()["items"] + ) + + +def test_shop_unknown_400_and_unique_409(client: TestClient, monkeypatch: Any) -> None: + assert client.post("/api/garden/shop/buy", json={"item_key": "nope"}).status_code == 400 + + # Уникальную декорацию нельзя купить дважды: 6 задач по 40 XP = 240 XP (L2), + # монеты 6×20 + бонус 50 = 170 ≥ 80 + monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99) + for _ in range(6): + _done(client, _create_task(client, priority=9, estimated_minutes=480)) + assert client.post("/api/garden/shop/buy", json={"item_key": "lantern"}).status_code == 200 + assert client.post("/api/garden/shop/buy", json={"item_key": "lantern"}).status_code == 409 + + +def test_shop_expansion_lock_and_order(client: TestClient, monkeypatch: Any) -> None: + # Уровень 1 < 3 — замок + resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_1"}) + assert resp.status_code == 403 + assert "3" in resp.json()["detail"] + + # До L3 нужен 300 XP: 8 задач по 40 = 320; монеты 8×20 + бонусы 50+75 = 285 + monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99) + for _ in range(8): + _done(client, _create_task(client, priority=9, estimated_minutes=480)) + state = client.get("/api/garden").json() + assert state["level"] == 3 + + resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_1"}) + assert resp.status_code == 200 + assert client.get("/api/garden").json()["grid"]["cols"] > 32 + + # Внепорядковое расширение (expansion_2 ещё следующий, 3 просит) — 409 + resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_3"}) + assert resp.status_code == 409 + + +# --- Авторазмещение и виды --- + + +def test_species_unlock_and_placement(client: TestClient) -> None: + state = client.get("/api/garden").json() + # Уровень 1: только стартовые виды, cactus закрыт (premium) + keys = [s["key"] for s in state["species"] if s["unlocked"]] + assert "ph-flower" in keys and "ph-cactus" not in keys + + _done(client, _create_task(client)) + state = client.get("/api/garden").json() + plants = [i for i in state["items"] if i["kind"] == "plant"] + assert len(plants) == 1 + # Размещение по спирали — вне footprint домика и в границах сетки + assert plants[0]["item_key"] in keys + assert 0 <= plants[0]["x"] < state["grid"]["cols"] + assert 0 <= plants[0]["y"] < state["grid"]["rows"] + + +def test_spiral_position_skips_occupied() -> None: + occupied: set[tuple[int, int]] = set() + first = spiral_position(occupied, 32, 20) + assert first == (13, 7) # радиус 3 от центра — первый шаг спирали + occupied.add(first) + second = spiral_position(occupied, 32, 20) + assert second not in occupied + + +def test_level_bonus_helper() -> None: + assert level_bonus_coins(2) == 50 + assert coins_for_xp(40) == 20 + assert set(EXPANSIONS) == {"expansion_1", "expansion_2", "expansion_3"} diff --git a/docs/TZ.md b/docs/TZ.md index ed1f232..635cfa3 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.13 | +| Версия ТЗ | 0.14 | | Дата | 2026-09-20 | | Статус | На обсуждении | @@ -177,12 +177,46 @@ - **Момент награды**: при закрытии задачи — короткая конфетти-анимация и тост «+N XP» с похвалой. Особые закрытия (редкое растение, крупная задача) — усиленное «золотое» конфетти. -- **Сад** (отдельная страница): каждая закрытая задача — растение на грядке; - сад только растёт. Рядом — уровень, **«последний росток»** (что и когда - закрыто последним), **история по месяцам** (12 месяцев, столбики) и ачивки. +- **Сад** (отдельная страница): живая **сцена** — графическое представление сада + с домиком в центре; вокруг сад на мелкой сетке (ячейка ~20px, привязка при + перемещении, наложения разрешены). Каждая закрытая задача — растение в саду; + сад только растёт, продавать и удалять растения нельзя. Растения и декорации + можно перетаскивать (drag&drop). Рядом — уровень, **кошелёк** (баланс монет), + **«последний росток»** (что и когда закрыто последним), **история по месяцам** + (12 месяцев, столбики) и ачивки. +- **Две награды: опыт и монеты.** Опыт — «ранг садовника»: только растёт, ничего + не открывает в магазине напрямую. Монеты — «валюта сада»: тратятся. Общий + принцип: **контент открывается за опыт, покупается за монеты** — опыт (уровень) + открывает виды растений и землю, монеты покупают рост растений, декорации и + расширения карты. +- **Монеты**: начисляются при закрытии задачи — `floor(xp / 2)` (заголовок + `X-Earned-Coins` рядом с XP в тосте), и бонусом **25 × L** при достижении + уровня L. Хранение — таблица `coin_events` (журнал заработка и трат; баланс = + сумма amount). Траты — отрицательные события (`source = shop`); продажи нет + («только позитив»). - **Редкие растения**: редкость решается в момент закрытия по весу задачи — чем больше XP, тем выше шанс редкого; эпик-растения — только у крупных закрытий. Редкость хранится в `xp_events`, визуально — золото + бейдж. +- **Растения и виды**: растение рождается только от закрытой задачи (отпечаток + работы), вид выбирается детерминированно из пула видов, открытых **уровнем на + момент рождения** (premium-виды — только для rare/epic закрытий), и + фиксируется за растением (`garden_items.item_key`) — открытые позже виды не + перерисовывают старые растения. Растение привязано к событию XP (переживает + удаление задачи). Каталог видов — в маркете сада с замками «откроется на + уровне N». +- **Рост растений за монеты**: растение растёт стадиями росток → куст → + цветение, каждая стадия покупается монетами (цена по редкости: + common 20/50, rare 40/100, epic 80/200). Автороста по времени нет. +- **Маркет сада**: в модалке — **расширения карты** (первым блоком), + **декорации** и **каталог видов**. У каждой покупки три состояния: 🔒 + «откроется на уровне N» (если уровень не открывает), цена золотом (кнопка + активна), цена серым + «не хватает N монет». + - **Декорации** — чисто косметика: фонарь, лавочка, поилка, пруд, беседка + (уникальные), изгородь и клумба (повторяемые). Размещаются автоматически на + свободной ячейке, двигаются как растения. + - **Расширения карты**: стартовая сетка 32×20 ячеек; каждое расширение + (+8 клеток периметра) открывается уровнем (I — ур. 3, II — ур. 5, + III — ур. 8) и покупается монетами (100/250/500). Финал ~56×44. - **Ачивки с тирами**: у каждой ачивки ступени I/II/III (например, 10/50/100 задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий, уровни, макс. задач за день, разные проекты, суммарные часы, строгие @@ -265,6 +299,19 @@ Attachment — файлы документов (изображения), document_id, mime. URL скачивания файла неизменен: /api/attachments/{id}/file — он зашит в сохранённый markdown-текст + +XpEvent — начисление XP за закрытую задачу (идемпотентно по task_id): + amount, rarity (common | rare | epic), via_options + +CoinEvent — журнал монет сада (заработок и траты; баланс = Σ amount): + task_id (nullable), level (nullable — бонус уровня), + source (task | level | shop), amount (+/−), item_key (ключ + покупки для трат), created_at + +GardenItem — элемент сцены сада: kind (plant | decoration), + ref_id (→ xp_events для растений; уникально в паре с kind), + item_key (вид растения / ключ декорации), x, y (ячейки сетки), + stage (0 росток, 1 куст, 2 цветение) ``` История завершённых задач (фактическое время) — источник для прогнозирования длительности. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e68694b..6085572 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -112,6 +112,45 @@ export type AppSettingsUpdate = Partial +// Сад-сцена (ТЗ 3.13): элементы, каталоги, магазин +export interface GardenItem { + id: number + kind: 'plant' | 'decoration' + ref_id: number | null + item_key: string | null + x: number + y: number + stage: number // 0 росток, 1 куст, 2 цветение + rarity: string | null + task_title: string | null + done_at: string | null +} + +export interface GardenSpecies { + key: string + level: number + premium: boolean + unlocked: boolean +} + +export interface GardenExpansion { + key: string + level: number + cost: number +} + +export interface GardenState { + balance: number + level: number + grid: { cols: number; rows: number } + items: GardenItem[] + decorations: { key: string; cost: number; unique: boolean }[] + next_expansion: GardenExpansion | null + expansions_bought: number + species: GardenSpecies[] + upgrade_costs: Record +} + export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -139,6 +178,7 @@ if (earned) { earnedXpBuffer = { xp: Number(earned) || 0, + coins: Number(res.headers.get('X-Earned-Coins')) || 0, rarity: res.headers.get('X-Plant-Rarity') ?? 'common', options: res.headers.get('X-Options-Close') === '1', hard: res.headers.get('X-Mentally-Hard') === '1', @@ -150,6 +190,7 @@ // Геймификация последнего закрывающего запроса (забирается через takeEarnedXp) export interface EarnedReward { xp: number + coins: number // монеты сада — вторая награда за закрытие rarity: string // common | rare | epic options: boolean // закрыто из режима «3 вариантов» hard: boolean // «ментально сложная» — награда за силу воли @@ -232,4 +273,21 @@ // геймификация (ТЗ 3.13) getXp: () => request('/api/xp'), getXpEvents: () => request('/api/xp/events'), + // сад (ТЗ 3.13) + getGarden: () => request('/api/garden'), + moveGardenItem: (id: number, x: number, y: number) => + request<{ ok: boolean }>(`/api/garden/items/${id}`, { + method: 'PATCH', + body: JSON.stringify({ x, y }), + }), + upgradePlant: (id: number) => + request<{ ok: boolean; stage: number; cost: number; balance: number }>( + `/api/garden/plants/${id}/upgrade`, + { method: 'POST' }, + ), + buyGardenItem: (itemKey: string) => + request<{ ok: boolean; balance: number }>('/api/garden/shop/buy', { + method: 'POST', + body: JSON.stringify({ item_key: itemKey }), + }), } \ No newline at end of file diff --git a/frontend/src/components/GardenScene.vue b/frontend/src/components/GardenScene.vue new file mode 100644 index 0000000..d5649f5 --- /dev/null +++ b/frontend/src/components/GardenScene.vue @@ -0,0 +1,428 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/gamification.ts b/frontend/src/gamification.ts index e1f2f72..53ca9de 100644 --- a/frontend/src/gamification.ts +++ b/frontend/src/gamification.ts @@ -66,7 +66,7 @@ // «Ментально сложная» празднуется золотом всегда — закрыть через силу само по себе повод const special = reward.rarity !== 'common' || reward.xp >= 25 || reward.hard confetti(special) - const titleParts = [`+${reward.xp} XP`] + const titleParts = [`+${reward.xp} XP`, `+${reward.coins} ${i18n.global.t('gamify.coins')}`] if (reward.options) titleParts.push(i18n.global.t('gamify.chosen')) if (reward.hard) titleParts.push(i18n.global.t('gamify.willpower')) toast?.success({ title: titleParts.join(' • '), text: rewardText(reward) }) diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index b848f90..ad4c6b7 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -57,6 +57,7 @@ 'Completing a task earns XP: base 10 + a priority bonus (up to 15) + a size bonus from the time estimate (up to 15) + 10 for a “mentally hard” task — an honest reward for willpower. Bigger and more urgent work is worth more.', 'Earned XP raises your level — from Sprout to Garden Legend. Levels never drop: XP does not expire and is not taken away, even if a task is deleted.', 'Sometimes a task yields a rare plant — the chance grows with the task weight. Epic plants only come from the biggest completions.', + 'The general rule: experience unlocks, coins buy. Experience (level) unlocks new plant species and land; coins are spent on plant growth, decorations and map expansion. Coins come from completed tasks (half the XP) and from reaching new levels.', 'Achievements mark milestones: tasks completed, levels, different projects, hours of work, strict deadlines, “mentally hard” tasks. Each has steps I, II, III with always-visible progress.', 'A task completed from the “3 options” mode earns the “chose and did” mark. Closing a “don’t want to do it” task is always celebrated in gold — the willpower was worth it. Nothing expires, no streaks or penalties — only pleasant rewards.', ], @@ -77,6 +78,44 @@ 'Garden legend', ], achievements: 'Achievements', + coins: 'coins: {n}', + coinsHint: 'Garden currency: spent on plant growth and decorations', + shop: 'Market', + shopTitle: 'Garden market', + buy: 'Buy', + notEnoughCoins: 'Not enough coins — {n} short', + lockedUntil: 'Unlocks at level {n}', + expansion: 'Garden expansion', + expansionHint: 'More land — {n} cells added to the map perimeter', + expansionCurrent: 'Current map: {cols}×{rows}', + expansionMax: 'All land unlocked', + decorations: 'Decorations', + decorationsHint: 'Decorate your garden — decorations give no bonuses, just joy', + unique: 'unique', + rareOnly: 'rare plants only', + species: 'Plant species', + speciesHint: + 'Experience unlocks new species and land, coins buy growth and décor. A species is fixed to a plant at birth.', + upgrade: 'Grow — {n} coins', + maxStage: 'Fully grown', + stage: { sprout: 'Sprout', bush: 'Bush', bloom: 'Bloom' }, + speciesNames: { + 'ph-flower': 'Flower', + 'ph-leaf': 'Greens', + 'ph-tree-evergreen': 'Pine', + 'ph-flower-lotus': 'Lotus', + 'ph-cactus': 'Cactus', + 'ph-tree': 'Oak', + }, + decorNames: { + fence: 'Fence', + flowerbed: 'Flowerbed', + lantern: 'Lantern', + bench: 'Bench', + birdbath: 'Birdbath', + pond: 'Pond', + gazebo: 'Gazebo', + }, ach: { firstDone: 'First sprout', done10: 'Dozen', @@ -105,6 +144,7 @@ chosen: 'chose and did', willpower: 'willpower', willpowerText: 'Willpower! It was worth it.', + coins: 'coins', }, stats: { title: 'Statistics', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 2872dab..cca2c37 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -58,6 +58,7 @@ 'За закрытие начисляется XP: база 10 + бонус за приоритет задачи (до 15) + бонус за объём работы по оценке времени (до 15) + 10 за «ментально сложную» — честная награда за силу воли. Крупные и срочные дела ценнее.', 'Накопленный XP повышает уровень — от «Ростка» до «Легенды сада». Уровень не падает: XP не сгорает и не отнимается, даже если задачу удалить.', 'Иногда задача приносит редкое растение — шанс тем выше, чем «весомее» задача. Эпические растения достаются только крупным закрытиям.', + 'Общий принцип: опыт открывает, монеты покупают. Опыт (уровень) открывает новые виды растений и землю; монеты тратятся на рост растений, декорации и расширение карты. Монеты приходят за закрытые задачи (половина XP) и за новые уровни.', 'Достижения отмечают вехи: закрытые задачи, уровни, разные проекты, часы работы, строгие дедлайны, «ментально сложные» задачи. У каждой ступени I, II, III — прогресс виден всегда.', 'Задача, закрытая из режима «3 вариантов», отмечается меткой «выбрал и сделал». Закрытие «не хочется делать» празднуется золотом — сила воли того стоила. Ничего не сгорает, серий и наказаний нет — только приятные награды.', ], @@ -78,6 +79,44 @@ 'Легенда сада', ], achievements: 'Достижения', + coins: 'монет: {n}', + coinsHint: 'Валюта сада: тратится на рост растений и декорации', + shop: 'Маркет', + shopTitle: 'Маркет сада', + buy: 'Купить', + notEnoughCoins: 'Не хватает {n} монет', + lockedUntil: 'Откроется на уровне {n}', + expansion: 'Расширение сада', + expansionHint: 'Больше земли — {n} клеток к периметру карты', + expansionCurrent: 'Текущая карта: {cols}×{rows}', + expansionMax: 'Вся земля открыта', + decorations: 'Декорации', + decorationsHint: 'Украсьте сад — декорации не дают бонусов, только настроение', + unique: 'уникальная', + rareOnly: 'только редкие растения', + species: 'Виды растений', + speciesHint: + 'Опыт открывает новые виды и землю, монеты покупают рост и украшения. Вид фиксируется за растением в момент рождения.', + upgrade: 'Вырастить — {n} монет', + maxStage: 'Полностью выросло', + stage: { sprout: 'Росток', bush: 'Куст', bloom: 'Цветение' }, + speciesNames: { + 'ph-flower': 'Цветок', + 'ph-leaf': 'Зелень', + 'ph-tree-evergreen': 'Ель', + 'ph-flower-lotus': 'Лотос', + 'ph-cactus': 'Кактус', + 'ph-tree': 'Дуб', + }, + decorNames: { + fence: 'Изгородь', + flowerbed: 'Клумба', + lantern: 'Фонарь', + bench: 'Лавочка', + birdbath: 'Поилка для птиц', + pond: 'Пруд', + gazebo: 'Беседка', + }, ach: { firstDone: 'Первый росток', done10: 'Десяток', @@ -106,6 +145,7 @@ chosen: 'выбрал и сделал', willpower: 'сила воли', willpowerText: 'Сила воли! Это того стоило.', + coins: 'монет', }, stats: { title: 'Статистика', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index 6b77f5d..43fcaca 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -57,6 +57,7 @@ 'За виконання нараховується XP: база 10 + бонус за пріоритет (до 15) + бонус за обсяг за оцінкою часу (до 15) + 10 за «ментально складне» — чесна нагорода за силу волі. Великі й термінові справи цінніші.', 'Накопичений XP підвищує рівень — від «Паростка» до «Легенди саду». Рівень не падає: XP не згорає і не віднімається, навіть якщо завдання видалити.', 'Іноді завдання приносить рідкісну рослину — шанс тим вищий, що «важче» завдання. Епічні рослини дістаються лише великим завершенням.', + 'Загальний принцип: досвід відкриває, монети купують. Досвід (рівень) відкриває нові види рослин і землю; монети витрачаються на ріст рослин, декорації та розширення карти. Монети надходять за виконані завдання (половина XP) та за нові рівні.', 'Досягнення відзначають віхи: виконані завдання, рівні, різні проєкти, години роботи, строгі дедлайни, «ментально складні» завдання. У кожного ступені I, II, III — прогрес видно завжди.', 'Завдання, виконане з режиму «3 варіанти», відзначається меткою «обрав і зробив». Закриття «не хочеться робити» завжди святкується золотом — сила волі того варта. Нічого не згорає, серій і покарань немає — лише приємні нагороди.', ], @@ -77,6 +78,44 @@ 'Легенда саду', ], achievements: 'Досягнення', + coins: 'монет: {n}', + coinsHint: 'Валюта саду: витрачається на ріст рослин і декорації', + shop: 'Маркет', + shopTitle: 'Маркет саду', + buy: 'Купити', + notEnoughCoins: 'Не вистачає {n} монет', + lockedUntil: 'Відкриється на рівні {n}', + expansion: 'Розширення саду', + expansionHint: 'Більше землі — {n} клітинок до периметра карти', + expansionCurrent: 'Поточна карта: {cols}×{rows}', + expansionMax: 'Вся земля відкрита', + decorations: 'Декорації', + decorationsHint: 'Прикрасьте сад — декорації не дають бонусів, лише настрій', + unique: 'унікальна', + rareOnly: 'лише рідкісні рослини', + species: 'Види рослин', + speciesHint: + 'Досвід відкриває нові види й землю, монети купують ріст та прикраси. Вид фіксується за рослиною в момент народження.', + upgrade: 'Виростити — {n} монет', + maxStage: 'Повністю виросло', + stage: { sprout: 'Паросток', bush: 'Кущ', bloom: 'Цвітіння' }, + speciesNames: { + 'ph-flower': 'Квітка', + 'ph-leaf': 'Зелень', + 'ph-tree-evergreen': 'Ялина', + 'ph-flower-lotus': 'Лотос', + 'ph-cactus': 'Кактус', + 'ph-tree': 'Дуб', + }, + decorNames: { + fence: 'Огорожа', + flowerbed: 'Клумба', + lantern: 'Фонар', + bench: 'Лавка', + birdbath: 'Поїлка для птахів', + pond: 'Ставок', + gazebo: 'Альтанка', + }, ach: { firstDone: 'Перший паросток', done10: 'Десятка', @@ -105,6 +144,7 @@ chosen: 'обрав і зробив', willpower: 'сила волі', willpowerText: 'Сила волі! Того варто було.', + coins: 'монет', }, stats: { title: 'Статистика', diff --git a/frontend/src/views/GardenView.vue b/frontend/src/views/GardenView.vue index ca99df4..5e1992b 100644 --- a/frontend/src/views/GardenView.vue +++ b/frontend/src/views/GardenView.vue @@ -1,20 +1,28 @@