From 3261781775c613271a7172bd3e3635eb0f806189 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 27 Jan 2022 00:35:37 +0200 Subject: [PATCH 1/2] feature: Achievements base. --- bot/modules/stats/__init__.py | 2 + bot/modules/stats/achievements.py | 386 ++++++++++++++++++++++++++++ bot/modules/study/tracking/data.py | 23 ++ data/migration/v9-v10/migration.sql | 40 +++ data/schema.sql | 4 +- 5 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 bot/modules/stats/achievements.py diff --git a/bot/modules/stats/__init__.py b/bot/modules/stats/__init__.py index cf342274..d835a785 100644 --- a/bot/modules/stats/__init__.py +++ b/bot/modules/stats/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .module import module from . import data @@ -5,3 +6,4 @@ from . import profile from . import setprofile from . import top_cmd from . import goals +from . import achievements diff --git a/bot/modules/stats/achievements.py b/bot/modules/stats/achievements.py new file mode 100644 index 00000000..36dfdb61 --- /dev/null +++ b/bot/modules/stats/achievements.py @@ -0,0 +1,386 @@ +from typing import NamedTuple, Optional, Union +from datetime import timedelta + +import pytz +import discord + +from cmdClient.checks import in_guild +from LionContext import LionContext + +from meta import client +from core import Lion +from data.conditions import NOTNULL, LEQ +from utils.lib import utc_now + +from .module import module + + +class AchievementLevel(NamedTuple): + name: str + threshold: Union[int, float] + emoji: discord.PartialEmoji + + +class Achievement: + """ + ABC for a member or user achievement. + """ + # Name of the achievement + name: str = None + + # List of levels for the achievement. Must always contain a 0 level! + levels: list[AchievementLevel] = None + + def __init__(self, guildid: int, userid: int): + self.guildid = guildid + self.userid = userid + + # Current status of the achievement. None until calculated by `update`. + self.value: int = None + + # Current level index in levels. None until calculated by `update`. + self.level_id: int = None + + @classmethod + async def fetch(cls, guildid: int, userid: int) -> 'Achievement': + """ + Fetch an Achievement status for the given member. + """ + return await cls(guildid, userid).update() + + @property + def level(self) -> AchievementLevel: + """ + The current `AchievementLevel` for this member achievement. + """ + if self.level_id is None: + raise ValueError("Cannot obtain level before first update!") + return self.levels[self.level_id] + + @property + def next_level(self) -> Optional[AchievementLevel]: + """ + The next `AchievementLevel` for this member achievement, + or `None` if it is at the maximum level. + """ + if self.level_id is None: + raise ValueError("Cannot obtain level before first update!") + + if self.level_id == len(self.levels) - 1: + return None + else: + return self.levels[self.level_id + 1] + + async def update(self) -> 'Achievement': + """ + Calculate and store the current member achievement status. + Returns `self` for easy chaining. + """ + # First fetch the value + self.value = await self._calculate_value() + + # Then determine the current level + # Using 0 as a fallback in case the value is negative + self.level_id = next( + (i for i, level in reversed(list(enumerate(self.levels))) if level.threshold <= self.value), + 0 + ) + + # And return `self` for chaining + return self + + async def _calculate_value(self) -> Union[int, float]: + """ + Calculate the current `value` of the member achievement. + Must be overridden by Achievement implementations. + """ + raise NotImplementedError + + +class Workout(Achievement): + name = "Workouts" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 50, None), + ] + + async def _calculate_value(self) -> int: + """ + Returns the total number of workouts from this user. + """ + return client.data.workout_sessions.select_one_where( + userid=self.userid, + select_columns="COUNT(*)" + )[0] + + +class StudyHours(Achievement): + name = "Study Hours" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 1000, None), + ] + + async def _calculate_value(self) -> float: + """ + Returns the total number of hours this user has studied. + """ + past_session_total = client.data.session_history.select_one_where( + userid=self.userid, + select_columns="SUM(duration)" + )[0] or 0 + current_session_total = client.data.current_sessions.select_one_where( + userid=self.userid, + select_columns="SUM(EXTRACT(EPOCH FROM (NOW() - start_time)))" + )[0] or 0 + + session_total = past_session_total + current_session_total + hours = session_total / 3600 + return hours + + +class StudyStreak(Achievement): + name = "Study Streak" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 100, None) + ] + + async def _calculate_value(self) -> int: + """ + Return the user's maximum global study streak. + """ + lion = Lion.fetch(self.guildid, self.userid) + history = client.data.session_history.select_where( + userid=self.userid, + select_columns=( + "start_time", + "(start_time + duration * interval '1 second') AS end_time" + ), + _extra="ORDER BY start_time DESC" + ) + + # Streak statistics + streak = 0 + max_streak = 0 + + day_attended = True if 'sessions' in client.objects and lion.session else None + date = lion.day_start + daydiff = timedelta(days=1) + + periods = [(row['start_time'], row['end_time']) for row in history] + + i = 0 + while i < len(periods): + row = periods[i] + i += 1 + if row[1] > date: + # They attended this day + day_attended = True + continue + elif day_attended is None: + # Didn't attend today, but don't break streak + day_attended = False + date -= daydiff + i -= 1 + continue + elif not day_attended: + # Didn't attend the day, streak broken + date -= daydiff + i -= 1 + pass + else: + # Attended the day + streak += 1 + + # Move window to the previous day and try the row again + day_attended = False + prev_date = date + date -= daydiff + i -= 1 + + # Special case, when the last session started in the previous day + # Then the day is already attended + if i > 1 and date < periods[i-2][0] <= prev_date: + day_attended = True + + continue + + max_streak = max(max_streak, streak) + streak = 0 + + # Handle loop exit state, i.e. the last streak + if day_attended: + streak += 1 + max_streak = max(max_streak, streak) + + return max_streak + + +class Voting(Achievement): + name = "Voting" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 100, None) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of times the user has voted for the bot. + """ + return client.data.topgg.select_one_where( + userid=self.userid, + select_columns="COUNT(*)" + )[0] + + +class DaysStudying(Achievement): + name = "Days Studied" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 90, None) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of days the user has studied in total. + """ + lion = Lion.fetch(self.guildid, self.userid) + offset = int(lion.day_start.utcoffset().total_seconds()) + with client.data.session_history.conn as conn: + cursor = conn.cursor() + # TODO: Consider DST offset. + cursor.execute( + """ + SELECT + COUNT(DISTINCT(date_trunc('day', (time AT TIME ZONE 'utc') + interval '{} seconds'))) + FROM ( + (SELECT start_time AS time FROM session_history WHERE userid=%s) + UNION + (SELECT (start_time + duration * interval '1 second') AS time FROM session_history WHERE userid=%s) + ) AS times; + """.format(offset), + (self.userid, self.userid) + ) + data = cursor.fetchone() + return data[0] + + +class TasksComplete(Achievement): + name = "Completed Tasks" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 1000, None) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of tasks the user has completed. + """ + return client.data.tasklist.select_one_where( + userid=self.userid, + completed_at=NOTNULL + )[0] + + +class ScheduledSessions(Achievement): + name = "Scheduled Sessions Attended" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 500, None) + ] + + async def _calculate_value(self) -> int: + """ + Returns the number of scheduled sesions the user has attended. + """ + return client.data.accountability_member_info.select_one_where( + userid=self.userid, + start_at=LEQ(utc_now()), + select_columns="COUNT(*)", + _extra="AND (duration > 0 OR last_joined_at IS NOT NULL)" + )[0] + + +class MonthlyHours(Achievement): + name = "Maximum Monthly Hours" + + levels = [ + AchievementLevel("Level 0", 0, None), + AchievementLevel("Level 1", 100, None) + ] + + async def _calculate_value(self) -> float: + """ + Returns the maximum number of hours the user has studied in a month. + """ + # Get the first session so we know how far back to look + first_session = client.data.session_history.select_one_where( + userid=self.userid, + select_columns="MIN(start_time)" + )[0] + + # Get the user's timezone + lion = Lion.fetch(self.guildid, self.userid) + + # If the first session doesn't exist, simulate an existing session (to avoid an extra lookup) + first_session = first_session or lion.day_start - timedelta(days=1) + + # Build the list of month start timestamps + month_start = lion.day_start.replace(day=1) + months = [month_start.astimezone(pytz.utc)] + + while month_start >= first_session: + month_start -= timedelta(days=1) + month_start = month_start.replace(day=1) + months.append(month_start.astimezone(pytz.utc)) + + # Query the study times + data = client.data.session_history.queries.study_times_since( + self.guildid, self.userid, *months + ) + cumulative_times = [row[0] for row in data] + print(cumulative_times) + times = [nxt - crt for nxt, crt in zip(cumulative_times[1:], cumulative_times[0:])] + max_time = max(cumulative_times[0], *times) if len(months) > 1 else cumulative_times[0] + + return max_time / 3600 + + +# Define the displayed achivement order +achievements = [ + Workout, + StudyHours, + StudyStreak, + Voting, + DaysStudying, + TasksComplete, + ScheduledSessions, + MonthlyHours +] + + +async def get_achievements_for(member): + status = [ + await ach.fetch(member.guild.id, member.id) + for ach in achievements + ] + return status + + +@module.cmd( + name="achievements", + desc="View your achievement progress!", + group="Statistics", +) +@in_guild() +async def cmd_achievements(ctx: LionContext): + achs = await get_achievements_for(ctx.author) + await ctx.reply('\n'.join(f"{ach.name}: {ach.level_id}, {ach.value}" for ach in achs)) diff --git a/bot/modules/study/tracking/data.py b/bot/modules/study/tracking/data.py index b3cb8dc7..549a7ca6 100644 --- a/bot/modules/study/tracking/data.py +++ b/bot/modules/study/tracking/data.py @@ -1,3 +1,5 @@ +from psycopg2.extras import execute_values + from data import Table, RowTable, tables from utils.lib import FieldEnum @@ -60,4 +62,25 @@ def study_time_since(guildid, userid, timestamp): return (rows[0][0] if rows else None) or 0 +@session_history.save_query +def study_times_since(guildid, userid, *timestamps): + """ + Retrieve the total member study time (in seconds) since the given timestamps. + Includes the current session, if it exists. + """ + with session_history.conn as conn: + cursor = conn.cursor() + data = execute_values( + cursor, + """ + SELECT study_time_since(t.guildid, t.userid, t.timestamp) + FROM (VALUES %s) + AS t (guildid, userid, timestamp) + """, + [(guildid, userid, timestamp) for timestamp in timestamps], + fetch=True + ) + return data + + members_totals = Table('members_totals') diff --git a/data/migration/v9-v10/migration.sql b/data/migration/v9-v10/migration.sql index 55dd5f32..a2fe7004 100644 --- a/data/migration/v9-v10/migration.sql +++ b/data/migration/v9-v10/migration.sql @@ -58,6 +58,46 @@ AS $$ $$ LANGUAGE PLPGSQL; +-- Add support for NULL guildid +DROP FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ); + +CREATE FUNCTION study_time_since(_guildid BIGINT, _userid BIGINT, _timestamp TIMESTAMPTZ) + RETURNS INTEGER +AS $$ + BEGIN + RETURN ( + SELECT + SUM( + CASE + WHEN start_time >= _timestamp THEN duration + ELSE EXTRACT(EPOCH FROM (end_time - _timestamp)) + END + ) + FROM ( + SELECT + start_time, + duration, + (start_time + duration * interval '1 second') AS end_time + FROM session_history + WHERE + (_guildid IS NULL OR guildid=_guildid) + AND userid=_userid + AND (start_time + duration * interval '1 second') >= _timestamp + UNION + SELECT + start_time, + EXTRACT(EPOCH FROM (NOW() - start_time)) AS duration, + NOW() AS end_time + FROM current_sessions + WHERE + (_guildid IS NULL OR guildid=_guildid) + AND userid=_userid + ) AS sessions + ); + END; +$$ LANGUAGE PLPGSQL; + + -- Rebuild study data views DROP VIEW current_sessions_totals CASCADE; diff --git a/data/schema.sql b/data/schema.sql index d0998ffd..69e9c89e 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -485,7 +485,7 @@ AS $$ (start_time + duration * interval '1 second') AS end_time FROM session_history WHERE - guildid=_guildid + (_guildid IS NULL OR guildid=_guildid) AND userid=_userid AND (start_time + duration * interval '1 second') >= _timestamp UNION @@ -495,7 +495,7 @@ AS $$ NOW() AS end_time FROM current_sessions WHERE - guildid=_guildid + (_guildid IS NULL OR guildid=_guildid) AND userid=_userid ) AS sessions ); From 08935c08e72bee8eaecff89b855ece3a1e5a30e8 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 27 Jan 2022 07:57:33 +0200 Subject: [PATCH 2/2] (stats): Basic `achievements` command. Wrap `config.emojis` in a new `MapDotProxy` for easier access. --- bot/meta/config.py | 34 ++++++++++- bot/modules/stats/achievements.py | 96 ++++++++++++++++++++++++++----- config/example-bot.conf | 27 +++++++++ 3 files changed, 142 insertions(+), 15 deletions(-) diff --git a/bot/meta/config.py b/bot/meta/config.py index e0913c55..819bdf42 100644 --- a/bot/meta/config.py +++ b/bot/meta/config.py @@ -33,6 +33,33 @@ class configEmoji(PartialEmoji): ) +class MapDotProxy: + """ + Allows dot access to an underlying Mappable object. + """ + __slots__ = ("_map", "_converter") + + def __init__(self, mappable, converter=None): + self._map = mappable + self._converter = converter + + def __getattribute__(self, key): + _map = object.__getattribute__(self, '_map') + if key == '_map': + return _map + if key in _map: + _converter = object.__getattribute__(self, '_converter') + if _converter: + return _converter(_map[key]) + else: + return _map[key] + else: + return object.__getattribute__(_map, key) + + def __getitem__(self, key): + return self._map.__getitem__(key) + + class Conf: def __init__(self, configfile, section_name="DEFAULT"): self.configfile = configfile @@ -49,9 +76,12 @@ class Conf: self.section_name = section_name if section_name in self.config else 'DEFAULT' self.default = self.config["DEFAULT"] - self.section = self.config[self.section_name] + self.section = MapDotProxy(self.config[self.section_name]) self.bot = self.section - self.emojis = self.config['EMOJIS'] if 'EMOJIS' in self.config else self.section + self.emojis = MapDotProxy( + self.config['EMOJIS'] if 'EMOJIS' in self.config else self.section, + converter=configEmoji.from_str + ) # Config file recursion, read in configuration files specified in every "ALSO_READ" key. more_to_read = self.section.getlist("ALSO_READ", []) diff --git a/bot/modules/stats/achievements.py b/bot/modules/stats/achievements.py index 36dfdb61..d74de14d 100644 --- a/bot/modules/stats/achievements.py +++ b/bot/modules/stats/achievements.py @@ -7,7 +7,7 @@ import discord from cmdClient.checks import in_guild from LionContext import LionContext -from meta import client +from meta import client, conf from core import Lion from data.conditions import NOTNULL, LEQ from utils.lib import utc_now @@ -41,6 +41,61 @@ class Achievement: # Current level index in levels. None until calculated by `update`. self.level_id: int = None + @staticmethod + def progress_bar(value, minimum, maximum, width=15) -> str: + """ + Build a text progress bar representing `value` between `minimum` and `maximum`. + """ + emojis = conf.emojis + + proportion = (value - minimum) / (maximum - minimum) + sections = max(int(proportion * width), 0) + + bar = [] + # Starting segment + bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full)) + + # Full segments up to transition or end + if sections >= 2: + bar.append(str(emojis.progress_middle_full) * (sections - 2)) + + # Transition, if required + if 1 < sections < width: + bar.append(str(emojis.progress_middle_transition)) + + # Empty sections up to end + bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1))) + + # End section + bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full)) + + # Join all the sections together and return + return ''.join(bar) + + @property + def progress_text(self) -> str: + """ + A brief textual description of the current progress. + Intended to be overridden by achievement implementations. + """ + if self.next_level: + return f"{int(self.value)}/{self.next_level.threshold}" + else: + return f"{int(self.value)}, at the maximum level!" + + def progress_field(self) -> tuple[str, str]: + """ + Builds the progress field for the achievement display. + """ + # TODO: Not adjusted for levels + # TODO: Add hint if progress is empty? + name = f"{self.levels[1].emoji} {self.name} ({self.progress_text})" + value = "**0** {progress_bar} **{threshold}**".format( + progress_bar=self.progress_bar(self.value, self.levels[0].threshold, self.levels[1].threshold), + threshold=self.levels[1].threshold + ) + return (name, value) + @classmethod async def fetch(cls, guildid: int, userid: int) -> 'Achievement': """ @@ -102,7 +157,7 @@ class Workout(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 50, None), + AchievementLevel("Level 1", 50, conf.emojis.active_achievement_1), ] async def _calculate_value(self) -> int: @@ -120,7 +175,7 @@ class StudyHours(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 1000, None), + AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_2), ] async def _calculate_value(self) -> float: @@ -146,7 +201,7 @@ class StudyStreak(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 100, None) + AchievementLevel("Level 1", 100, conf.emojis.active_achievement_3) ] async def _calculate_value(self) -> int: @@ -225,7 +280,7 @@ class Voting(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 100, None) + AchievementLevel("Level 1", 100, conf.emojis.active_achievement_4) ] async def _calculate_value(self) -> int: @@ -243,7 +298,7 @@ class DaysStudying(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 90, None) + AchievementLevel("Level 1", 90, conf.emojis.active_achievement_5) ] async def _calculate_value(self) -> int: @@ -276,7 +331,7 @@ class TasksComplete(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 1000, None) + AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_6) ] async def _calculate_value(self) -> int: @@ -294,7 +349,7 @@ class ScheduledSessions(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 500, None) + AchievementLevel("Level 1", 500, conf.emojis.active_achievement_7) ] async def _calculate_value(self) -> int: @@ -314,7 +369,7 @@ class MonthlyHours(Achievement): levels = [ AchievementLevel("Level 0", 0, None), - AchievementLevel("Level 1", 100, None) + AchievementLevel("Level 1", 100, conf.emojis.active_achievement_8) ] async def _calculate_value(self) -> float: @@ -347,7 +402,6 @@ class MonthlyHours(Achievement): self.guildid, self.userid, *months ) cumulative_times = [row[0] for row in data] - print(cumulative_times) times = [nxt - crt for nxt, crt in zip(cumulative_times[1:], cumulative_times[0:])] max_time = max(cumulative_times[0], *times) if len(months) > 1 else cumulative_times[0] @@ -377,10 +431,26 @@ async def get_achievements_for(member): @module.cmd( name="achievements", - desc="View your achievement progress!", + desc="View your progress towards the achievements!", group="Statistics", ) @in_guild() async def cmd_achievements(ctx: LionContext): - achs = await get_achievements_for(ctx.author) - await ctx.reply('\n'.join(f"{ach.name}: {ach.level_id}, {ach.value}" for ach in achs)) + """ + Usage``: + {prefix}achievements + Description: + View your progress towards attaining the achievement badges shown on your `profile`. + """ + status = await get_achievements_for(ctx.author) + + embed = discord.Embed( + title="Achievements", + colour=discord.Colour.orange() + ) + for achievement in status: + name, value = achievement.progress_field() + embed.add_field( + name=name, value=value, inline=False + ) + await ctx.reply(embed=embed) diff --git a/config/example-bot.conf b/config/example-bot.conf index 4cc55a85..d0fed43d 100644 --- a/config/example-bot.conf +++ b/config/example-bot.conf @@ -25,3 +25,30 @@ support_link = https://discord.gg/StudyLions [EMOJIS] lionyay = lionlove = + +progress_left_empty = +progress_left_full = +progress_middle_empty = +progress_middle_full = +progress_middle_transition = +progress_right_empty = +progress_right_full = + + +inactive_achievement_1 = +inactive_achievement_2 = +inactive_achievement_3 = +inactive_achievement_4 = +inactive_achievement_5 = +inactive_achievement_6 = +inactive_achievement_7 = +inactive_achievement_8 = + +active_achievement_1 = +active_achievement_2 = +active_achievement_3 = +active_achievement_4 = +active_achievement_5 = +active_achievement_6 = +active_achievement_7 = +active_achievement_8 =