diff --git a/backend/app/api/garden.py b/backend/app/api/garden.py index 5e9451e..425d7d7 100644 --- a/backend/app/api/garden.py +++ b/backend/app/api/garden.py @@ -21,6 +21,7 @@ house_footprint, make_seed_plant, next_expansion, + reposition_out_of_bounds, species_catalog, ) from app.services.xp import level_from_xp, total_xp @@ -42,6 +43,8 @@ def _state(db: Any, user_id: str) -> dict[str, Any]: """Полное состояние сцены после ленивого обслуживания.""" ensure_garden(db, user_id) + # сад стал компактнее (ТЗ 0.37): за границей стоящее переставляем + reposition_out_of_bounds(db, user_id) level = level_from_xp(total_xp(db, user_id)) size = grid_size(db, user_id) diff --git a/backend/app/services/garden.py b/backend/app/services/garden.py index b228eaa..4bc5858 100644 --- a/backend/app/services/garden.py +++ b/backend/app/services/garden.py @@ -8,7 +8,7 @@ from typing import Any -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -57,16 +57,16 @@ } # Расширения карты: ключ -> (уровень открытия, цена). Каждое добавляет -# EXPANSION_PERIMETER клеток и к ширине, и к высоте; старт 32×20. -BASE_COLS = 32 -BASE_ROWS = 20 +# 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": 5, "cost": 250}, - "expansion_3": {"level": 8, "cost": 500}, - "expansion_4": {"level": 9, "cost": 750}, - "expansion_5": {"level": 11, "cost": 1000}, + "expansion_2": {"level": 12, "cost": 250}, } # Виды растений: открываются уровнем («контент за опыт»); premium — только @@ -142,6 +142,46 @@ 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) diff --git a/backend/tests/test_garden.py b/backend/tests/test_garden.py index 294b991..d12a66b 100644 --- a/backend/tests/test_garden.py +++ b/backend/tests/test_garden.py @@ -3,7 +3,9 @@ from typing import Any from fastapi.testclient import TestClient +from sqlalchemy import select +from app.models import GardenItem from app.services.garden import ( DECORATIONS, EXPANSIONS, @@ -11,6 +13,7 @@ level_bonus_coins, spiral_position, ) +from tests.conftest import AUTH_USER, _test_session_factory def _create_task(client: TestClient, **patch: Any) -> int: @@ -175,12 +178,18 @@ 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 + # ТЗ 0.37: старт 24×12, первое расширение возвращает прежний старт 32×20 + assert client.get("/api/garden").json()["grid"] == {"cols": 32, "rows": 20} - # Внепорядковое расширение (expansion_2 ещё следующий, 3 просит) — 409 - resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_3"}) + # Повторное расширение_1 (следующее — expansion_2) — 409 + resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_1"}) assert resp.status_code == 409 + # Второе расширение открывается на уровне 12 (ТЗ 0.37) — пока замок + resp = client.post("/api/garden/shop/buy", json={"item_key": "expansion_2"}) + assert resp.status_code == 403 + assert "12" in resp.json()["detail"] + # --- Авторазмещение и виды --- @@ -203,20 +212,44 @@ def test_spiral_position_skips_occupied() -> None: occupied: set[tuple[int, int]] = set() - first = spiral_position(occupied, 32, 20) - assert first == (13, 7) # радиус 3 от центра — первый шаг спирали + first = spiral_position(occupied, 24, 12) + assert first == (9, 3) # радиус 3 от центра — первый шаг спирали occupied.add(first) - second = spiral_position(occupied, 32, 20) + second = spiral_position(occupied, 24, 12) assert second not in occupied +def test_reposition_out_of_bounds(client: TestClient) -> None: + """ТЗ 0.37: сад стал компактнее — предметы за границей переставляются + в пределы сетки при первом заходе (ничего не теряется, идемпотентно).""" + from app.services.garden import reposition_out_of_bounds + + session = _test_session_factory() + try: + # позиция за новой стартовой границей 24×12 (как в старых садах) + session.add( + GardenItem(user_id=AUTH_USER["user_id"], kind="plant", item_key="ph-flower", x=26, y=15) + ) + session.commit() + item_id = session.scalars(select(GardenItem.id).where(GardenItem.x == 26)).one() + finally: + session.close() + + state = client.get("/api/garden").json() + assert state["grid"] == {"cols": 24, "rows": 12} + item = next(i for i in state["items"] if i["id"] == item_id) + assert 0 <= item["x"] < 24 and 0 <= item["y"] < 12 + + # идемпотентность: повторный вызов — 0 + session = _test_session_factory() + try: + assert reposition_out_of_bounds(session, AUTH_USER["user_id"]) == 0 + finally: + session.close() + + 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", - "expansion_4", - "expansion_5", - } + # ТЗ 0.37: лестница расширений сокращена до двух шагов + assert set(EXPANSIONS) == {"expansion_1", "expansion_2"} diff --git a/docs/TZ.md b/docs/TZ.md index 906fae2..bcd9807 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.36 | +| Версия ТЗ | 0.37 | | Дата | 2026-09-22 | | Статус | На обсуждении | @@ -332,10 +332,11 @@ инвентаря декорация перетаскивается на карту; перетаскивание размещённой декорации обратно на полосу инвентаря убирает её с карты (растения в инвентарь вернуть нельзя — сад только растёт). - - **Расширения карты**: стартовая сетка 32×20 ячеек; каждое расширение - (+8 клеток периметра) открывается уровнем (I — ур. 3, II — ур. 5, - III — ур. 8, IV — ур. 9, V — ур. 11) и покупается монетами - (100/250/500/750/1000). Финал ~72×60. + - **Расширения карты** (0.37): стартовая сетка **24×12** ячеек — компактный + дворик заполняется быстро, расширение ощущается событием. Первое расширение + (ур. 3, 100 монет) возвращает размер прежнего старта 32×20, второе + (ур. 12, 250 монет) — до 40×28; у существующих садов предметы, оказавшиеся + за новой границей, при первом заходе переставляются по спирали к дому. - **Ачивки с тирами**: у каждой ачивки ступени I/II/III (например, 10/50/100 задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий, уровни, макс. задач за день, разные проекты, суммарные часы, строгие