File size: 5,279 Bytes
d896946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""All XP changes go through here.

Safety properties
  * Every award is written to the immutable ledger (xp_events) FIRST; the INSERT is idempotent
    (UNIQUE user+kind+ref), so retries / double taps / replays can never pay twice.
  * users.points / week_xp / day_xp are updated with ONE atomic SQL statement (no lost updates).
  * Automatic sources are capped per day and per kind; the daily cap is enforced here, not in handlers.
"""
from dataclasses import dataclass
from datetime import timedelta
from typing import Optional
from uuid import uuid4

from aiogram import Bot
from sqlalchemy import case, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import set_committed_value

from app.core import cache, season
from app.core.i18n import t
from app.core.notify import safe_send
from app.db.models import User
from app.db.repos import xp as xp_repo
from app.services import xp_rules as R


@dataclass
class AwardResult:
    amount: int = 0
    old_level: int = 1
    new_level: int = 1

    @property
    def leveled_up(self) -> bool:
        return self.new_level > self.old_level


async def award(
    session: AsyncSession, user: User, kind: str, amount: int, ref: Optional[str] = None, *, capped: bool = True
) -> AwardResult:
    if amount <= 0 or user.is_banned:
        return AwardResult()
    now = season.now_et()
    wk, dk = season.week_key(now), season.day_key(now)

    daily = capped and kind in R.CAPPED_KINDS
    if daily:
        used_today = user.day_xp if user.day_key == dk else 0
        amount = min(amount, R.DAILY_CAP - used_today)
        per_kind = R.PER_KIND_DAILY.get(kind)
        if amount > 0 and per_kind is not None:
            if await xp_repo.count_kind_on_day(session, user.id, kind, dk) >= per_kind:
                amount = 0
        if amount <= 0:
            return AwardResult()

    if not await xp_repo.insert_event(session, user.id, kind, amount, ref, wk, dk):
        return AwardResult()  # already paid for this exact action

    values = dict(
        points=User.points + amount,
        week_xp=case((User.week_key == wk, User.week_xp + amount), else_=amount),
        week_key=wk,
    )
    if daily:
        values.update(day_xp=case((User.day_key == dk, User.day_xp + amount), else_=amount), day_key=dk)
    stmt = (
        update(User)
        .where(User.id == user.id)
        .values(**values)
        .returning(User.points, User.week_xp, User.week_key, User.day_xp, User.day_key)
        .execution_options(synchronize_session=False)
    )
    row = (await session.execute(stmt)).one()
    old_points = user.points or 0
    for name, value in zip(("points", "week_xp", "week_key", "day_xp", "day_key"), row):
        set_committed_value(user, name, value)  # keep the in-memory copy right without marking it dirty
    session.sync_session.info["wrote"] = True
    return AwardResult(amount, R.level_for(old_points)[0], R.level_for(row[0])[0])


async def adjust_trust(session: AsyncSession, user_id: int, delta: int, reason: str, ref: Optional[str] = None) -> bool:
    """Lower (or raise) a user's trust score. Logged in the ledger as a 0-XP 'flag' event."""
    now = season.now_et()
    event_ref = ref or f"{reason}:{uuid4().hex[:8]}"
    if not await xp_repo.insert_event(session, user_id, "flag", 0, event_ref, season.week_key(now), season.day_key(now)):
        return False
    new_trust = User.trust + delta
    await session.execute(
        update(User)
        .where(User.id == user_id)
        .values(trust=case((new_trust < 0, 0), (new_trust > 100, 100), else_=new_trust))
        .execution_options(synchronize_session=False)
    )
    session.sync_session.info["wrote"] = True
    session.sync_session.info.setdefault("invalidate", set()).add(user_id)
    return True


async def on_activity(session: AsyncSession, user: User, bot: Bot) -> None:
    """Called once per update from a gate-passed user: daily check-in, streak, level-up messages."""
    now = season.now_et()
    today = season.day_key(now)
    last = user.last_active_on.isoformat() if user.last_active_on else None
    if last == today:
        return
    yesterday = season.day_key(now - timedelta(days=1))
    user.streak = R.next_streak(last, today, user.streak or 0, yesterday)
    user.best_streak = max(user.best_streak or 0, user.streak)
    user.last_active_on = now.date()
    user.active_days = (user.active_days or 0) + 1

    result = await award(session, user, "checkin", R.checkin_xp(user.streak), ref=today)
    bonus = R.STREAK_MILESTONES.get(user.streak, 0)
    bonus_result = await award(session, user, "streak_bonus", bonus, ref=f"s{user.streak}:{today}", capped=False) if bonus else AwardResult()

    if bonus_result.amount:
        await safe_send(bot, user.id, t(user.lang, "streak_bonus", streak=user.streak, xp=bonus_result.amount))
    final_level = bonus_result.new_level if bonus_result.amount else result.new_level
    if final_level > result.old_level:
        await announce_level(bot, user, final_level)


async def announce_level(bot: Bot, user: User, level: int) -> None:
    await safe_send(bot, user.id, t(user.lang, "levelup", level=level, title=t(user.lang, f"level_{level}")))


def stale_cache(user_id: int) -> None:
    cache.invalidate(user_id)