diff --git a/backend/alembic/versions/a1f2c3d4e5f6_xp_events_table.py b/backend/alembic/versions/a1f2c3d4e5f6_xp_events_table.py index 6ee883d..95dfc53 100644 --- a/backend/alembic/versions/a1f2c3d4e5f6_xp_events_table.py +++ b/backend/alembic/versions/a1f2c3d4e5f6_xp_events_table.py @@ -33,4 +33,4 @@ def downgrade() -> None: """Downgrade schema.""" - op.drop_table('xp_events') \ No newline at end of file + op.drop_table('xp_events') diff --git a/backend/alembic/versions/b7e4d5c6a8f9_xp_rarity_via_options.py b/backend/alembic/versions/b7e4d5c6a8f9_xp_rarity_via_options.py new file mode 100644 index 0000000..2b1a408 --- /dev/null +++ b/backend/alembic/versions/b7e4d5c6a8f9_xp_rarity_via_options.py @@ -0,0 +1,34 @@ +"""Геймификация: редкость растения и признак «из 3 вариантов» в xp_events + +Revision ID: b7e4d5c6a8f9 +Revises: a1f2c3d4e5f6 +Create Date: 2026-09-20 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "b7e4d5c6a8f9" +down_revision: str | Sequence[str] | None = "a1f2c3d4e5f6" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "xp_events", + sa.Column("rarity", sa.String(length=16), nullable=False, server_default="common"), + ) + op.add_column( + "xp_events", + sa.Column("via_options", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade() -> None: + op.drop_column("xp_events", "via_options") + op.drop_column("xp_events", "rarity") diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 8b61c1f..3491377 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -133,6 +133,8 @@ if len(tags) != len(set(tag_ids)): raise HTTPException(status_code=400, detail="Unknown tag id in tag_ids") task.tags = list(tags) + # Пометка источника закрытия (геймификация): не поле задачи, в setattr не идёт + earned_via = data.pop("earned_via", None) for field, value in data.items(): setattr(task, field, value) @@ -144,9 +146,13 @@ if spawned is not None: db.flush() # Геймификация (ТЗ 3.13): XP за закрытие — один раз на задачу - earned = grant_task_xp(db, task) - if earned: - response.headers["X-Earned-XP"] = str(earned) + event = grant_task_xp(db, task) + if event is not None: + event.via_options = earned_via == "options" + response.headers["X-Earned-XP"] = str(event.amount) + response.headers["X-Plant-Rarity"] = event.rarity + if event.via_options: + response.headers["X-Options-Close"] = "1" db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления diff --git a/backend/app/api/xp.py b/backend/app/api/xp.py index 2d2ab68..ce35eae 100644 --- a/backend/app/api/xp.py +++ b/backend/app/api/xp.py @@ -1,11 +1,11 @@ -"""API геймификации (ТЗ 3.13): XP, уровень, прогресс.""" +"""API геймификации (ТЗ 3.13): XP, уровень, прогресс, события начисления.""" from typing import Any from fastapi import APIRouter from app.dependencies import DbDep, UserDep -from app.services.xp import xp_summary +from app.services.xp import xp_events, xp_summary router = APIRouter(prefix="/api/xp", tags=["xp"]) @@ -14,3 +14,9 @@ async def get_xp(db: DbDep, user: UserDep) -> dict[str, Any]: """Сводка: суммарный XP, уровень, прогресс до следующего.""" return xp_summary(db) + + +@router.get("/events") +async def get_xp_events(db: DbDep, user: UserDep) -> list[dict[str, Any]]: + """Все события начисления: редкость растения, признак «из 3 вариантов», даты.""" + return xp_events(db) diff --git a/backend/app/models.py b/backend/app/models.py index 8dbca50..63ac52a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -171,4 +171,8 @@ ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, default=None ) amount: Mapped[int] = mapped_column(Integer) + # Редкость растения в саду — решается в момент закрытия по весу задачи + rarity: Mapped[str] = mapped_column(String(16), default="common") + # Закрыто из режима «3 вариантов» (ТЗ 3.9) — для счётчика «выбрал и сделал» + via_options: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c2836d0..0c414c0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -57,6 +57,8 @@ recur_interval_days: int | None = Field(None, ge=1, le=365) recur_weekdays: str | None = Field(None, pattern=r"^[1-7](,[1-7])*$") # ISO, пн=1 recur_day_of_month: int | None = Field(None, ge=1, le=31) + # Не поле задачи: пометка источника закрытия для геймификации ("options" — режим «3 вариантов») + earned_via: str | None = None class TaskOut(BaseModel): diff --git a/backend/app/services/xp.py b/backend/app/services/xp.py index 6a8038f..d8b3943 100644 --- a/backend/app/services/xp.py +++ b/backend/app/services/xp.py @@ -4,6 +4,7 @@ не отнимается (удаление задачи не трогает заработанное, стриков/серий нет). """ +import random from typing import Any from sqlalchemy import func, select @@ -50,15 +51,44 @@ return xp -def grant_task_xp(db: Session, task: Task) -> int: - """Начислить XP за закрытие задачи (один раз на задачу, идемпотентно).""" +# Редкость растения (ТЗ 3.13): чем «весомее» задача (больше XP), тем выше шанс. +# epic — только для крупных закрытий; rare — линейный рост от XP. +EPIC_XP_THRESHOLD = 25 +EPIC_CHANCE = 0.08 +RARE_BASE_CHANCE = 0.05 +RARE_CHANCE_PER_XP = 0.02 + + +def compute_rarity(xp: int, roll: float) -> str: + """Редкость растения по весу задачи: roll из random.random() (для тестов подаётся). + + Порядок проверки: epic -> rare -> common. + """ + if xp >= EPIC_XP_THRESHOLD and roll < EPIC_CHANCE: + return "epic" + rare_chance = RARE_BASE_CHANCE + max(xp - XP_BASE, 0) * RARE_CHANCE_PER_XP + if roll < rare_chance: + return "rare" + return "common" + + +def grant_task_xp(db: Session, task: Task) -> XpEvent | None: + """Начислить XP за закрытие задачи (один раз на задачу, идемпотентно). + + Возвращает созданное событие (с rarity) или None, если XP уже начислен. + """ existing = db.scalar(select(XpEvent).where(XpEvent.task_id == task.id)) if existing is not None: - return 0 - event = XpEvent(task_id=task.id, amount=compute_task_xp(task)) + return None + amount = compute_task_xp(task) + event = XpEvent( + task_id=task.id, + amount=amount, + rarity=compute_rarity(amount, random.random()), + ) db.add(event) db.flush() - return event.amount + return event def total_xp(db: Session) -> int: @@ -88,3 +118,18 @@ "level_percent": round((xp - current_floor) / span * 100) if span else 100, "completed_count": int(db.scalar(select(func.count(XpEvent.id))) or 0), } + + +def xp_events(db: Session) -> list[dict[str, Any]]: + """Все события начисления (для сада: редкости, счётчик «выбрал и сделал», история).""" + rows = db.scalars(select(XpEvent).order_by(XpEvent.created_at.desc())).all() + return [ + { + "task_id": r.task_id, + "amount": r.amount, + "rarity": r.rarity, + "via_options": r.via_options, + "created_at": r.created_at, + } + for r in rows + ] diff --git a/backend/tests/test_xp.py b/backend/tests/test_xp.py index b2441ca..2f89537 100644 --- a/backend/tests/test_xp.py +++ b/backend/tests/test_xp.py @@ -1,9 +1,11 @@ -"""Геймификация (ТЗ 3.13): XP за закрытие задач, уровни, идемпотентность.""" +"""Геймификация (ТЗ 3.13): XP за закрытие задач, уровни, идемпотентность, редкость.""" from typing import Any from fastapi.testclient import TestClient +from app.services.xp import compute_rarity + def _create_task(client: TestClient, **patch: Any) -> int: resp = client.post("/api/tasks", json={"title": "Задача для XP"}) @@ -67,3 +69,54 @@ assert summary["level_xp"] == 0 assert summary["level_span"] == 200 assert summary["level_percent"] == 0 + + +def test_compute_rarity_boundaries() -> None: + # epic: только крупные закрытия и только при малом roll + assert compute_rarity(30, 0.079) == "epic" + assert compute_rarity(30, 0.081) == "rare" # мимо epic, но rare-шанс 45% + assert compute_rarity(30, 0.5) == "common" + assert compute_rarity(20, 0.5) == "common" # при xp < 25 epic недоступен вообще + # rare: шанс 5% + (xp-10)*2%; xp=10 -> 5%, xp=40 -> 65% + assert compute_rarity(10, 0.049) == "rare" + assert compute_rarity(10, 0.05) == "common" + assert compute_rarity(40, 0.649) == "rare" + assert compute_rarity(40, 0.65) == "common" + # при большом roll всегда common + assert compute_rarity(40, 0.99) == "common" + + +def test_xp_granted_with_rarity_and_options(client: TestClient) -> None: + task_id = _create_task(client) + resp = client.patch( + f"/api/tasks/{task_id}", json={"status": "done", "earned_via": "options"} + ) + assert resp.status_code == 200 + assert resp.headers.get("X-Earned-XP") == "10" + assert resp.headers.get("X-Plant-Rarity") == "common" + assert resp.headers.get("X-Options-Close") == "1" + + events = client.get("/api/xp/events").json() + assert len(events) == 1 + assert events[0]["task_id"] == task_id + assert events[0]["amount"] == 10 + assert events[0]["rarity"] == "common" + assert events[0]["via_options"] is True + + # закрытие из обычного списка — via_options не ставится + task2 = _create_task(client) + resp2 = client.patch(f"/api/tasks/{task2}", json={"status": "done"}) + assert resp2.headers.get("X-Plant-Rarity") == "common" + assert "X-Options-Close" not in resp2.headers + events = client.get("/api/xp/events").json() + assert {e["via_options"] for e in events} == {True, False} + + +def test_rarity_persists_after_grant(client: TestClient) -> None: + # событие с rarity (common при большинстве роллов) переживает перечитывание + task_id = _create_task(client) + resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) + rarity = resp.headers.get("X-Plant-Rarity") + assert rarity in ("common", "rare", "epic") + events = client.get("/api/xp/events").json() + assert events[0]["rarity"] == rarity diff --git a/docs/TZ.md b/docs/TZ.md index 104cd15..81d2717 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.8 | +| Версия ТЗ | 0.9 | | Дата | 2026-09-20 | | Статус | На обсуждении | @@ -167,12 +167,24 @@ объём по оценке времени (0–15). Начисление — один раз на задачу (идемпотентно); удаление задачи не отнимает заработанное. Хранение — таблица `xp_events`. - **Уровень**: растёт из суммарного XP (уровень не падает); прогресс до следующего - уровня показывается в «Саду». + уровня и **название звания** показываются в «Саду». Звания ботанические + (Росток, Сеянцев, Садовник, … Легенда сада — 10 званий). - **Момент награды**: при закрытии задачи — короткая конфетти-анимация и тост - «+N XP» с похвалой. + «+N XP» с похвалой. Особые закрытия (редкое растение, крупная задача) — + усиленное «золотое» конфетти. - **Сад** (отдельная страница): каждая закрытая задача — растение на грядке; - сад только растёт. Рядом — уровень и **ачивки-вехи** (10/50 задач, уровень 5/10, - закрытая регулярная, закрытый проект) с видимым прогрессом. + сад только растёт. Рядом — уровень, **«последний росток»** (что и когда + закрыто последним), **история по месяцам** (12 месяцев, столбики) и ачивки. +- **Редкие растения**: редкость решается в момент закрытия по весу задачи — + чем больше XP, тем выше шанс редкого; эпик-растения — только у крупных + закрытий. Редкость хранится в `xp_events`, визуально — золото + бейдж. +- **Ачивки с тирами**: у каждой ачивки ступени I/II/III (например, 10/50/100 + задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий, + уровни, макс. задач за день, разные проекты, суммарные часы, строгие + дедлайны, закрытия через режим «3 вариантов» (см. 3.9), регулярная, проект. +- **Связка с «3 вариантами»** (3.9): закрытие задачи из режима помечается + (`earned_via`) и празднуется меткой «выбрал и сделал» + счётчик-ачивкой; + XP при этом не меняется. ## 4. Нефункциональные требования diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1262ff0..fa82cce 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -78,6 +78,8 @@ recur_interval_days?: number | null recur_weekdays?: string | null recur_day_of_month?: number | null + /** Не поле задачи: источник закрытия для геймификации ("options" — режим «3 вариантов») */ + earned_via?: string } export interface XpSummary { @@ -89,6 +91,14 @@ completed_count: number } +export interface XpEvent { + task_id: number | null + amount: number + rarity: string + via_options: boolean + created_at: string +} + export interface AppSettings { currency: string /** '' = нет переопределения, язык берётся из SSO locale */ @@ -118,18 +128,30 @@ const body = await res.json().catch(() => ({ detail: res.statusText })) throw new ApiError(res.status, body.detail ?? i18n.global.t('common.error')) } - // Геймификация: бэк помечает закрытие задачи заголовком X-Earned-XP + // Геймификация: бэк помечает закрытие задачи заголовками (XP, редкость, «3 варианта») const earned = res.headers.get('X-Earned-XP') - if (earned) earnedXpBuffer = Number(earned) || 0 + if (earned) { + earnedXpBuffer = { + xp: Number(earned) || 0, + rarity: res.headers.get('X-Plant-Rarity') ?? 'common', + options: res.headers.get('X-Options-Close') === '1', + } + } return res.json() as Promise } -// XP, начисленный последним запросом (забирается через takeEarnedXp) -let earnedXpBuffer = 0 +// Геймификация последнего закрывающего запроса (забирается через takeEarnedXp) +export interface EarnedReward { + xp: number + rarity: string // common | rare | epic + options: boolean // закрыто из режима «3 вариантов» +} -export function takeEarnedXp(): number { +let earnedXpBuffer: EarnedReward | null = null + +export function takeEarnedXp(): EarnedReward | null { const value = earnedXpBuffer - earnedXpBuffer = 0 + earnedXpBuffer = null return value } @@ -201,4 +223,5 @@ request('/api/settings', { method: 'PUT', body: JSON.stringify(patch) }), // геймификация (ТЗ 3.13) getXp: () => request('/api/xp'), + getXpEvents: () => request('/api/xp/events'), } \ No newline at end of file diff --git a/frontend/src/gamification.ts b/frontend/src/gamification.ts index 59e7872..f4fa3c0 100644 --- a/frontend/src/gamification.ts +++ b/frontend/src/gamification.ts @@ -1,7 +1,10 @@ // Геймификация (ТЗ 3.13): микронаграда за закрытие задачи — конфетти // и тост «+N XP» с похвалой. Только позитив: ничего не сгорает, серий нет. +// Особые закрытия (редкое растение, крупная задача, режим «3 вариантов») +// празднуются «золотым» конфетти и расширенным тостом. import { takeEarnedXp } from './api' +import type { EarnedReward } from './api' import i18n from './i18n' // Регистрация toast-провайдера кита (вызывается один раз в App.vue setup) @@ -20,32 +23,49 @@ return typeof phrase === 'string' ? phrase : '' } -// CSS-конфетти: частицы падают сверху с вращением, слой чистится сам +// CSS-конфетти: частицы падают сверху с вращением, слой чистится сам. +// gold=true — «золотой» вариант: больше частиц, тёплая палитра. const CONFETTI_COLORS = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#f7768e', '#73daca'] +const GOLD_COLORS = ['#d4a017', '#f1c40f', '#f5d76e', '#b8860b', '#ffffff', '#e6c34a'] -function confetti(): void { +function confetti(gold: boolean): void { + const colors = gold ? GOLD_COLORS : CONFETTI_COLORS const layer = document.createElement('div') layer.className = 'confetti-layer' - for (let i = 0; i < 32; i++) { + const pieces = gold ? 56 : 32 + for (let i = 0; i < pieces; i++) { const piece = document.createElement('span') piece.className = 'confetti-piece' piece.style.left = `${5 + Math.random() * 90}%` - piece.style.background = CONFETTI_COLORS[i % CONFETTI_COLORS.length] - piece.style.animationDelay = `${Math.random() * 0.4}s` + piece.style.background = colors[i % colors.length] + piece.style.animationDelay = `${Math.random() * (gold ? 0.6 : 0.4)}s` piece.style.animationDuration = `${1.1 + Math.random() * 0.9}s` piece.style.setProperty('--rot', `${-540 + Math.floor(Math.random() * 1080)}deg`) piece.style.setProperty('--drift', `${-60 + Math.floor(Math.random() * 120)}px`) layer.appendChild(piece) } document.body.appendChild(layer) - window.setTimeout(() => layer.remove(), 2400) + window.setTimeout(() => layer.remove(), 2600) +} + +// Текст тоста: похвала + приписки за редкое растение и режим «3 вариантов» +function rewardText(r: EarnedReward): string { + const parts: string[] = [] + if (r.rarity === 'epic') parts.push(i18n.global.t('gamify.epicPlant')) + else if (r.rarity === 'rare') parts.push(i18n.global.t('gamify.rarePlant')) + parts.push(randomPraise()) + return parts.filter(Boolean).join(' ') } // Вызывать после любого действия, которое могло закрыть задачу: // если бэк начислил XP (заголовок X-Earned-XP) — праздник + тост export function celebrateEarned(): void { - const xp = takeEarnedXp() - if (!xp) return - confetti() - toast?.success({ title: `+${xp} XP`, text: randomPraise() }) + const reward = takeEarnedXp() + if (!reward || !reward.xp) return + const special = reward.rarity !== 'common' || reward.xp >= 25 + confetti(special) + const title = reward.options + ? `+${reward.xp} XP • ${i18n.global.t('gamify.chosen')}` + : `+${reward.xp} XP` + toast?.success({ title, text: rewardText(reward) }) } \ No newline at end of file diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 127fab5..ce9312b 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -47,29 +47,49 @@ level: 'Level {n}', xpTotal: '{n} XP', plants: 'plants: {n}', - levelProgress: 'To next level: {xp}/{span} XP', + levelProgress: 'To next level: {xp} / {span} XP', + lastPlant: 'Latest sprout: {title} — {date}', + history: 'History: tasks completed by month', emptyTitle: 'The garden is empty', - emptyText: 'Complete your first task and a plant will grow here.', + emptyText: 'Complete your first task — a plant will grow on the bed.', + levels: [ + 'Sprout', + 'Seedling', + 'Gardener', + 'Grower', + 'Arborist', + 'Forester', + 'Garden master', + 'Garden keeper', + 'Royal gardener', + 'Garden legend', + ], achievements: 'Achievements', ach: { firstDone: 'First sprout', - done10: 'Perfect ten', - done50: 'Half hundred', - level5: 'Level 5', - level10: 'Level 10', - recurring: 'Life rhythm', - projectDone: 'Project done', + done10: 'Dozen', + level5: 'Heights', + sprinter: 'Sprinter', + explorer: 'All-rounder', + marathon: 'Marathoner', + deadlineBoss: 'Deadline boss', + chooser: 'Chose and did', + recurring: 'Rhythm of life', + projectDone: 'Project delivered', }, }, gamify: { praise: [ 'Well done!', - 'Done — half the battle!', - 'One more step forward.', + 'Done — halfway there!', + 'Another step forward.', 'Beautiful!', 'Keep it up!', - 'One task less, one mood more.', + 'One task down, mood up.', ], + rarePlant: 'A rare plant has grown on your bed!', + epicPlant: 'A legendary plant in your garden!', + chosen: 'chose and did', }, home: { title: 'Home', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index f31273d..790a7e4 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -49,15 +49,32 @@ xpTotal: '{n} XP', plants: 'растений: {n}', levelProgress: 'До уровня: {xp} / {span} XP', + lastPlant: 'Последний росток: {title} — {date}', + history: 'История: закрытых задач по месяцам', emptyTitle: 'Сад пока пуст', emptyText: 'Закройте первую задачу — на грядке появится растение.', + levels: [ + 'Росток', + 'Сеянцев', + 'Садовник', + 'Огородник', + 'Дендролог', + 'Лесничий', + 'Садовый мастер', + 'Хранитель сада', + 'Королевский садовник', + 'Легенда сада', + ], achievements: 'Достижения', ach: { firstDone: 'Первый росток', done10: 'Десяток', - done50: 'Полсотни', - level5: 'Уровень 5', - level10: 'Уровень 10', + level5: 'Высоты', + sprinter: 'Спринтер', + explorer: 'Разносторонний', + marathon: 'Марафонец', + deadlineBoss: 'Дедлайн-босс', + chooser: 'Выбрал и сделал', recurring: 'Ритм жизни', projectDone: 'Проект под ключ', }, @@ -71,6 +88,9 @@ 'Так держать!', 'Минус одна задача, плюс настроение.', ], + rarePlant: 'На грядке редкое растение!', + epicPlant: 'Легендарное растение в саду!', + chosen: 'выбрал и сделал', }, home: { title: 'Главное', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index 9f1aa2a..5cd0062 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -47,16 +47,33 @@ level: 'Рівень {n}', xpTotal: '{n} XP', plants: 'рослин: {n}', - levelProgress: 'До наступного рівня: {xp}/{span} XP', + levelProgress: 'До наступного рівня: {xp} / {span} XP', + lastPlant: 'Останній паросток: {title} — {date}', + history: 'Історія: виконаних завдань за місяцями', emptyTitle: 'Сад поки порожній', emptyText: 'Закрийте перше завдання — на грядці зросте рослина.', + levels: [ + 'Паросток', + 'Сіянець', + 'Садівник', + 'Городник', + 'Дендролог', + 'Лісничий', + 'Садовий майстер', + 'Хранитель саду', + 'Королівський садівник', + 'Легенда саду', + ], achievements: 'Досягнення', ach: { firstDone: 'Перший паросток', done10: 'Десятка', - done50: 'Півсотні', - level5: 'Рівень 5', - level10: 'Рівень 10', + level5: 'Висоти', + sprinter: 'Спринтер', + explorer: 'Різнобічний', + marathon: 'Марафонець', + deadlineBoss: 'Дедлайн-бос', + chooser: 'Обрав і зробив', recurring: 'Ритм життя', projectDone: 'Проєкт під ключ', }, @@ -70,6 +87,9 @@ 'Так тримати!', 'Мінус одне завдання, плюс настрій.', ], + rarePlant: 'На грядці зросла рідкісна рослина!', + epicPlant: 'Легендарна рослина в саду!', + chosen: 'обрав і зробив', }, home: { title: 'Головне', diff --git a/frontend/src/views/GardenView.vue b/frontend/src/views/GardenView.vue index 2dbafe0..2320505 100644 --- a/frontend/src/views/GardenView.vue +++ b/frontend/src/views/GardenView.vue @@ -1,17 +1,19 @@