Improve subathon modularity.

This commit is contained in:
2025-09-22 23:30:45 +10:00
parent 899f5e7292
commit 033fc5c1ae
3 changed files with 477 additions and 183 deletions

191
subathon/channel.py Normal file
View File

@@ -0,0 +1,191 @@
from typing import Optional, TypeAlias, TypedDict
from collections import defaultdict
from datetime import datetime, timedelta
from dataclasses import dataclass
from meta.sockets import Channel
from utils.lib import utc_now
from modules.profiles.profiles.profiles import ProfilesRegistry
from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal
from .subathon import ActiveSubathon, SubathonRegistry
# ISO formatted timestamp
ISOTimestamp: TypeAlias = str
class GoalPayload(TypedDict):
required: float
name: str
class ContributionPayload(TypedDict):
user_name: str
user_id: str | None
amount: float
seconds_added: int
timestamp: ISOTimestamp
class ScoreTablePayload(TypedDict):
bit_score: float # points per bit
t1_score: float # points per T1 sub
t2_score: float # points per T2 sub
t3_score: float # points per T3 sub
score_time: float # seconds added per point
class SubathonPayload(TypedDict):
end_at: ISOTimestamp
is_running: bool
score_table: ScoreTablePayload
name: str
total_contribution: float
goals_met: int
goals_total: int
last_goal: GoalPayload | None
next_goal: GoalPayload | None
last_contribution: ContributionPayload | None
async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon) -> SubathonPayload:
now = utc_now()
total_score = await subathon.get_score()
goals = await subathon.get_goals()
goals_met = 0
last_goal = None
next_goal = None
total_goals = len(goals)
for goal in goals:
if goal.required_score >= total_score:
last_goal = goal
goals_met += 1
else:
next_goal = goal
break
contribs = await subathon.fetch_contributions().limit(1)
last_contrib: ContributionPayload | None = None
if contribs:
contrib = contribs[0]
if contrib.profileid:
profile = await profiler.get_profile(contrib.profileid)
assert profile is not None
name = profile.nickname
user_id = None
else:
name = 'Anonymous'
user_id = None
last_contrib = ContributionPayload(
user_name=name,
user_id=user_id,
amount=contrib.score,
seconds_added=subathon.get_score_time(contrib.score),
timestamp=contrib.created_at.isoformat()
)
score_table = ScoreTablePayload(
bit_score=subathon.subathondata.bit_score,
t1_score=subathon.subathondata.sub1_score,
t2_score=subathon.subathondata.sub2_score,
t3_score=subathon.subathondata.sub3_score,
score_time=subathon.subathondata.score_time,
)
payload = SubathonPayload(
name=subathon.subathondata.name,
end_at=(await subathon.get_ending()).isoformat(),
is_running=(subathon.running),
score_table=score_table,
total_contribution=await subathon.get_score(),
goals_met=goals_met,
goals_total=total_goals,
last_goal=GoalPayload(
required=last_goal.required_score,
name=last_goal.description,
) if last_goal is not None else None,
next_goal=GoalPayload(
required=next_goal.required_score,
name=next_goal.description,
) if next_goal is not None else None,
last_contribution=last_contrib
)
return payload
class TimerChannel(Channel):
name = 'SubTimer'
def __init__(self, profiler: ProfilesRegistry, subathons: SubathonRegistry, **kwargs):
super().__init__(**kwargs)
self.profiler: ProfilesRegistry = profiler
self.subathons: SubathonRegistry = subathons
# Map of communities to webhooks listening for this community
self.communities = defaultdict(set) # Map of communityid -> listening websockets
async def on_connection(self, websocket, event):
if not (cidstr := event.get('community')):
raise ValueError("Subathon timer connection missing communityid")
elif not cidstr.isdigit():
raise ValueError("Community id provided is not an integer")
cid = int(cidstr)
community = await self.profiler.get_community(cid)
if community is None:
raise ValueError("Unknown community provided.")
await super().on_connection(websocket, event)
self.communities[cid].add(websocket)
subathon = await self.subathons.get_active_subathon(cid)
if subathon:
payload = await prepare_subathon(self.profiler, subathon)
await self.send_subathon_update(cid, payload, websocket)
else:
await self.send_no_subathon(cid, websocket)
async def del_connection(self, websocket):
for wss in self.communities.values():
wss.discard(websocket)
await super().del_connection(websocket)
async def send_subathon_update(self, communityid: int, payload: SubathonPayload, websocket=None):
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{
'type': "DO",
'method': "setTimer",
'args': payload,
},
websocket=ws
)
async def send_subathon_ended(self, communityid: int, payload: SubathonPayload, websocket=None):
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{
'type': "DO",
'method': "endTimer",
'args': payload,
},
websocket=ws
)
async def send_no_subathon(self, communityid: int, websocket=None):
for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event(
{
'type': "DO",
'method': "noTimer",
'args': {},
},
websocket=ws
)

View File

@@ -1,7 +1,6 @@
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import random
import twitchio import twitchio
from twitchio import PartialUser, Scopes, eventsub from twitchio import PartialUser, Scopes, eventsub
from twitchio.ext import commands as cmds from twitchio.ext import commands as cmds
@@ -13,147 +12,8 @@ from utils.lib import utc_now, strfdelta
from . import logger from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal
from .subathon import ActiveSubathon, SubathonRegistry
from .channel import SubathonPayload, prepare_subathon, TimerChannel
class TimerChannel(Channel):
name = 'SubTimer'
def __init__(self, cog: 'SubathonComponent', **kwargs):
super().__init__(**kwargs)
self.cog = cog
# This is horribly inefficient but will be deprecated by the web update
self.communities = defaultdict(set) # Map of communityid -> listening websockets
async def on_connection(self, websocket, event):
# TODO: Properly this should be communityid
# Which is retrieved via API call to the profiles module for the channel
# This should also be a different error so we can pass it back to the client.
if not event.get('channelid', None):
raise ValueError("Subtimer connection missing channel!")
community = await self.cog.bot.profiles.profiles.get_community_twitch(event['channelid'])
if community is None:
raise ValueError('Requested channel is not registered. Add the bot first.')
await super().on_connection(websocket, event)
self.communities[community.communityid].add(websocket)
await self.send_set(
**await self.get_args_for(community.communityid),
websocket=websocket,
)
async def del_connection(self, websocket):
for wss in self.communities.values():
wss.discard(websocket)
await super().del_connection(websocket)
async def send_updates(self, communityid: int):
args = await self.get_args_for(communityid)
for ws in self.communities[communityid]:
await self.send_set(
**args,
websocket=ws
)
async def get_args_for(self, communityid: int):
active = await self.cog.get_active_subathon(communityid)
if active is not None:
ending = utc_now() + timedelta(seconds=await active.get_remaining())
# TODO: Next goal info and overall goal progress, maybe last contrib
return {
'end_at': ending,
'running': active.running
}
else:
return {
'end_at': utc_now,
'running': False,
}
async def send_set(self, end_at, running, websocket=None):
await self.send_event({
'type': "DO",
'method': 'setTimer',
'args': {
'end_at': end_at.isoformat(),
'running': running,
}
}, websocket=websocket)
class ActiveSubathon:
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
self.subathondata = subathondata
self.runningdata = runningdata
@property
def running(self):
return self.runningdata is not None
@property
def name(self):
return self.subathondata.name or 'Subathon'
async def check_cap(self):
if not (cap := self.subathondata.timecap):
return False
else:
score = await self.get_score()
time_earned = self.get_score_time(score)
total_time = self.subathondata.initial_time + time_earned
return total_time >= cap
async def pause(self):
if not self.running:
raise ValueError("This subathon is not running!")
assert self.runningdata is not None
new_duration = self.get_duration()
await self.subathondata.update(duration=new_duration)
await self.runningdata.delete()
self.runningdata = None
async def resume(self):
if self.running:
raise ValueError("This subathon is already running!")
self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id)
async def get_score(self) -> float:
rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id)
return sum(row.score for row in rows)
def get_score_time(self, score: float) -> int:
# Get time contributed by this score
return int(score * self.subathondata.score_time)
def get_duration(self) -> int:
# Get the duration of this subathon so far
duration = self.subathondata.duration
if self.runningdata:
now = utc_now()
added = int( (now - self.runningdata.last_started).total_seconds() )
duration += added
return duration
async def get_remaining(self) -> int:
# Get the remaining time
score = await self.get_score()
time_earned = self.get_score_time(score)
total_time = self.subathondata.initial_time + time_earned
if cap := self.subathondata.timecap:
total_time = min(total_time, cap)
return total_time - self.get_duration()
async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution:
return await SubathonContribution.create(
subathon_id=self.subathondata.subathon_id,
profileid=profileid, score=score, event_id=event_id
)
async def get_goals(self) -> list[SubathonGoal]:
goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score')
return goals
class SubathonComponent(cmds.Component): class SubathonComponent(cmds.Component):
@@ -163,25 +23,29 @@ class SubathonComponent(cmds.Component):
def __init__(self, bot: Bot): def __init__(self, bot: Bot):
self.bot = bot self.bot = bot
self.data = bot.dbconn.load_registry(SubathonData()) self.data = bot.dbconn.load_registry(SubathonData())
self.channel = TimerChannel(self) self.subathons = SubathonRegistry(self.data, bot.profiles.profiles)
self.channel = TimerChannel(self.bot.profiles.profiles, self.subathons)
register_channel('SubTimer', self.channel) register_channel('SubTimer', self.channel)
# ----- API ----- # ----- API -----
async def component_load(self): async def component_load(self):
await self.data.init() await self.data.init()
await self.subathons.init()
await self.bot.version_check(*self.data.VERSION) await self.bot.version_check(*self.data.VERSION)
async def component_teardown(self): async def component_teardown(self):
pass pass
async def dispatch_update(self, subathon: ActiveSubathon):
# TODO: Fix confusion of responsibility for preparation
cid = subathon.subathondata.communityid
payload = await prepare_subathon(self.bot.profiles.profiles, subathon)
await self.channel.send_subathon_update(cid, payload)
# ----- Methods ----- # ----- Methods -----
async def get_active_subathon(self, communityid: int) -> ActiveSubathon | None: async def get_active_subathon(self, *args, **kwargs):
rows = await Subathon.fetch_where(communityid=communityid, ended_at=None) return await self.subathons.get_active_subathon(*args, **kwargs)
if rows:
subathondata = rows[0]
running = await RunningSubathon.fetch(subathondata.subathon_id)
subba = ActiveSubathon(subathondata, running)
return subba
async def goalcheck(self, active: ActiveSubathon, channel: PartialUser): async def goalcheck(self, active: ActiveSubathon, channel: PartialUser):
goals = await active.get_goals() goals = await active.get_goals()
@@ -199,7 +63,7 @@ class SubathonComponent(cmds.Component):
@cmds.Component.listener() @cmds.Component.listener()
async def event_safe_bits_use(self, payload): async def event_safe_bits_use(self, payload):
event_row, detail_row, bits_payload = payload event_row, detail_row, bits_payload = payload
if (active := await self.get_active_subathon(event_row['communityid'])) is not None: if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished():
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row['profileid']
uid = event_row['user_id'] uid = event_row['user_id']
@@ -226,7 +90,7 @@ class SubathonComponent(cmds.Component):
contrib_str, contrib_str,
sender=self.bot.bot_id sender=self.bot.bot_id
) )
await self.channel.send_updates(event_row['communityid']) await self.dispatch_update(active)
await self.goalcheck(active, bits_payload.broadcaster) await self.goalcheck(active, bits_payload.broadcaster)
# Check goals # Check goals
@@ -237,7 +101,7 @@ class SubathonComponent(cmds.Component):
# Ignore gifted here # Ignore gifted here
return return
if (active := await self.get_active_subathon(event_row['communityid'])) is not None: if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished():
data = active.subathondata data = active.subathondata
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row['profileid']
@@ -272,7 +136,7 @@ class SubathonComponent(cmds.Component):
contrib_str, contrib_str,
sender=self.bot.bot_id sender=self.bot.bot_id
) )
await self.channel.send_updates(event_row['communityid']) await self.dispatch_update(active)
# Check goals # Check goals
await self.goalcheck(active, sub_payload.broadcaster) await self.goalcheck(active, sub_payload.broadcaster)
@@ -280,7 +144,7 @@ class SubathonComponent(cmds.Component):
async def event_safe_subscription_gift(self, payload): async def event_safe_subscription_gift(self, payload):
event_row, detail_row, gift_payload = payload event_row, detail_row, gift_payload = payload
if (active := await self.get_active_subathon(event_row['communityid'])) is not None: if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished():
data = active.subathondata data = active.subathondata
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row['profileid']
@@ -314,7 +178,7 @@ class SubathonComponent(cmds.Component):
contrib_str, contrib_str,
sender=self.bot.bot_id sender=self.bot.bot_id
) )
await self.channel.send_updates(event_row['communityid']) await self.dispatch_update(active)
# Check goals # Check goals
await self.goalcheck(active, gift_payload.broadcaster) await self.goalcheck(active, gift_payload.broadcaster)
@@ -322,7 +186,7 @@ class SubathonComponent(cmds.Component):
async def event_safe_subscription_message(self, payload): async def event_safe_subscription_message(self, payload):
event_row, detail_row, sub_payload = payload event_row, detail_row, sub_payload = payload
if (active := await self.get_active_subathon(event_row['communityid'])) is not None: if (active := await self.get_active_subathon(event_row['communityid'])) is not None and not await active.check_finished():
data = active.subathondata data = active.subathondata
# In an active subathon # In an active subathon
pid = event_row['profileid'] pid = event_row['profileid']
@@ -357,7 +221,7 @@ class SubathonComponent(cmds.Component):
contrib_str, contrib_str,
sender=self.bot.bot_id sender=self.bot.bot_id
) )
await self.channel.send_updates(event_row['communityid']) await self.dispatch_update(active)
# Check goals # Check goals
await self.goalcheck(active, sub_payload.broadcaster) await self.goalcheck(active, sub_payload.broadcaster)
@@ -369,11 +233,12 @@ class SubathonComponent(cmds.Component):
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is not None:
if active.running: if active.running:
await active.pause() await active.pause()
if not await active.check_finished():
await payload.broadcaster.send_message( await payload.broadcaster.send_message(
"Paused the subathon timer because the stream went offline!", "Paused the subathon timer because the stream went offline!",
sender=self.bot.bot_id sender=self.bot.bot_id
) )
await self.channel.send_updates(cid) await self.dispatch_update(active)
# ----- Commands ----- # ----- Commands -----
@@ -388,15 +253,21 @@ class SubathonComponent(cmds.Component):
donegoals = len([goal for goal in goals if score >= goal.required_score]) donegoals = len([goal for goal in goals if score >= goal.required_score])
goalstr = f"{donegoals}/{total_goals} goals achieved" goalstr = f"{donegoals}/{total_goals} goals achieved"
secs = await active.get_remaining() dursecs = await active.get_duration()
remaining = strfdelta(timedelta(seconds=secs)) duration = strfdelta(timedelta(seconds=dursecs))
secs = active.get_duration() secs = await active.get_remaining()
duration = strfdelta(timedelta(seconds=secs)) if secs >= 0:
remaining = strfdelta(timedelta(seconds=secs))
text = ( text = (
f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer" f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer"
) )
else:
text = (
f"{active.name} completed after {duration} with a total of {score} points, and {goalstr}!"
)
await ctx.reply(text) await ctx.reply(text)
else: else:
await ctx.reply("No active subathon running!") await ctx.reply("No active subathon running!")
@@ -415,7 +286,7 @@ class SubathonComponent(cmds.Component):
sub3_points = points per T3 sub sub3_points = points per T3 sub
bit_points = points per bit bit_points = points per bit
timepoints = seconds to be added to the timer per point timepoints = seconds to be added to the timer per point
timecap (optional) = number of seconds to cap the timer at. timecap (optional) = number of hours to cap the timer at
""" """
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
@@ -423,8 +294,9 @@ class SubathonComponent(cmds.Component):
await ctx.reply("There is already an active subathon running! Use !subathon stop to stop it!") await ctx.reply("There is already an active subathon running! Use !subathon stop to stop it!")
return return
initial_time = initial_hours * 60 * 60 initial_time = initial_hours * 60 * 60
timecap_seconds = timecap * 60 * 60 if timecap else None
active = await Subathon.create( subdata = await Subathon.create(
communityid=cid, communityid=cid,
name=name, name=name,
initial_time=initial_time, initial_time=initial_time,
@@ -433,12 +305,13 @@ class SubathonComponent(cmds.Component):
sub3_score=sub3, sub3_score=sub3,
bit_score=bit, bit_score=bit,
score_time=timescore, score_time=timescore,
timecap=timecap timecap=timecap_seconds
) )
base_timer_url = self.bot.config.subathon['timer_url'] base_timer_url = self.bot.config.subathon['timer_url']
timer_link = f"{base_timer_url}?channelid={ctx.channel.id}" timer_link = f"{base_timer_url}?community={cid}"
await ctx.reply(f"Setup your {name}! Use !subathon resume to get the timer running. Your timer link: {timer_link}") await ctx.reply(f"Setup your {name}! Use !subathon resume to get the timer running. Your timer link: {timer_link}")
await self.channel.send_updates(cid) active = ActiveSubathon(subdata, None)
await self.dispatch_update(active)
# subathon stop # subathon stop
@group_subathon.command(name='stop') @group_subathon.command(name='stop')
@@ -449,14 +322,14 @@ class SubathonComponent(cmds.Component):
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is not None:
if active.running: if active.running:
await active.pause() await active.pause()
await self.channel.send_updates(cid) await self.dispatch_update(active)
await active.subathondata.update(ended_at=utc_now()) await active.subathondata.update(ended_at=utc_now())
total = await active.get_score() total = await active.get_score()
dursecs = active.get_duration() dursecs = await active.get_duration()
dur = strfdelta(timedelta(seconds=dursecs)) dur = strfdelta(timedelta(seconds=dursecs))
await ctx.reply( await ctx.reply(
f"{active.name} complete after {dur} with a total of {total} points, congratulations!" f"{active.name} ended after {dur} with a total of {total} points, congratulations!"
) )
else: else:
await ctx.reply("No active subathon to stop.") await ctx.reply("No active subathon to stop.")
@@ -471,7 +344,7 @@ class SubathonComponent(cmds.Component):
if active.running: if active.running:
await active.pause() await active.pause()
await ctx.reply(f"{active.name} timer paused!") await ctx.reply(f"{active.name} timer paused!")
await self.channel.send_updates(cid) await self.dispatch_update(active)
else: else:
await ctx.reply(f"{active.name} timer already paused!") await ctx.reply(f"{active.name} timer already paused!")
else: else:
@@ -484,12 +357,14 @@ class SubathonComponent(cmds.Component):
community = await self.bot.profiles.fetch_community(ctx.broadcaster) community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None: if (active := await self.get_active_subathon(cid)) is not None:
if not active.running: if active.running:
await ctx.reply(f"{active.name} timer already running!")
elif await active.check_finished():
await ctx.reply(f"{active.name} has already finished!")
else:
await active.resume() await active.resume()
await ctx.reply(f"{active.name} timer resumed!") await ctx.reply(f"{active.name} timer resumed!")
await self.channel.send_updates(cid) await self.dispatch_update(active)
else:
await ctx.reply(f"{active.name} timer already running!")
else: else:
await ctx.reply("No active subathon to resume") await ctx.reply("No active subathon to resume")
@@ -522,7 +397,7 @@ class SubathonComponent(cmds.Component):
name = None name = None
pid = None pid = None
await active.add_contribution(pid, amount, None) await active.add_contribution(pid, amount, None)
await self.channel.send_updates(cid) await self.dispatch_update(active)
# Build message # Build message
if amount > 0: if amount > 0:
@@ -538,6 +413,43 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to adjust") await ctx.reply("No active subathon to adjust")
# subathon config
@group_subathon.command(name='config', aliases=('option',))
@cmds.is_moderator()
async def cmd_subathon_config(self, ctx: Context, option: str, *, value: Optional[str] = None):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None:
if option.lower() == 'cap':
if value:
# Set the timer cap
if not value.isdigit():
await ctx.reply("Timer cap must be an integer number of hours!")
else:
await active.subathondata.update(timecap=int(value * 60 * 60))
await self.dispatch_update(active)
await ctx.reply(
f"The timer cap has been set to {value} hours."
)
else:
# Display the timer cap
cap = active.subathondata.timecap
if cap:
hours = cap / 3600
await ctx.reply(f"The timer cap is currently {hours} hours")
elif option.lower() == 'name':
if value:
await active.subathondata.update(name=value)
await self.dispatch_update(active)
await ctx.reply(f"Updated the subathon name to \"{value}\"")
else:
name = active.subathondata.name
await ctx.reply(f"This subathon is called \"{name}\"")
else:
await ctx.reply(
f"Unknown option {option}! Configurable options: 'name', 'cap'"
)
@group_subathon.command(name='leaderboard', aliases=('top', 'lb',)) @group_subathon.command(name='leaderboard', aliases=('top', 'lb',))
async def cmd_subathon_lb(self, ctx: Context): async def cmd_subathon_lb(self, ctx: Context):
""" """
@@ -615,6 +527,29 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon running!") await ctx.reply("No active subathon running!")
@group_goals.command(name='remaining', aliases=('left',))
async def cmd_goals_remaining(self, ctx: cmds.Context):
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None:
score = await active.get_score()
goals = await active.get_goals()
goalstrs = []
for i, goal in enumerate(goals, start=1):
if goal.required_score > score:
line = f"{goal.required_score} points: {goal.description}"
goalstrs.append(line)
if goalstrs
text = ', '.join(goalstrs)
await ctx.reply(f"{active.name} Goals Remaining -- {text}")
elif goals:
await ctx.reply(f"All goals completed, congratulations!")
else:
await ctx.reply("No goals have been configured!")
else:
await ctx.reply("No active subathon running!")
@group_goals.command(name='add') @group_goals.command(name='add')
@cmds.is_moderator() @cmds.is_moderator()
async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str): async def cmd_add(self, ctx: cmds.Context, required: int, *, description: str):
@@ -630,3 +565,23 @@ class SubathonComponent(cmds.Component):
else: else:
await ctx.reply("No active subathon to add goal to!") await ctx.reply("No active subathon to add goal to!")
# remove
@group_goals.command(name='remove', aliases=['del', 'delete', 'rm'])
@cmds.is_moderator()
async def cmd_goals_remove(self, ctx: cmds.Context, required: int):
"""
Description:
Remove any goal(s) set at the given score.
"""
community = await self.bot.profiles.fetch_community(ctx.broadcaster)
cid = community.communityid
if (active := await self.get_active_subathon(cid)) is not None:
results = await SubathonGoal.table.delete_where(
subathon_id=active.subathondata.subathon_id,
required_score=required,
)
if results:
await ctx.reply(f"Score {required} goal(s) removed!")
else:
await ctx.reply(f"No goal set at {required} score to remove.")

148
subathon/subathon.py Normal file
View File

@@ -0,0 +1,148 @@
from asyncio import Event
from datetime import datetime, timedelta
from utils.lib import utc_now, strfdelta
from modules.profiles.profiles.profiles import ProfilesRegistry
from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal
class ActiveSubathon:
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
self.subathondata = subathondata
self.runningdata = runningdata
@property
def running(self):
return self.runningdata is not None
@property
def name(self):
return self.subathondata.name or 'Subathon'
async def check_cap(self):
if not (cap := self.subathondata.timecap):
return False
else:
score = await self.get_score()
time_earned = self.get_score_time(score)
total_time = self.subathondata.initial_time + time_earned
return total_time >= cap
async def check_finished(self):
"""
Return True if the subathon duration has exceeded its earned time.
"""
return (await self.get_duration() <= 0)
async def pause(self):
"""
Pause the active subathon.
If the subathon is in 'overtime', i.e. it has already finished,
then the total time is saved as the earned time not including the overtime.
"""
if not self.running:
raise ValueError("This subathon is not running!")
assert self.runningdata is not None
new_duration = await self.get_duration()
await self.subathondata.update(duration=new_duration)
await self.runningdata.delete()
self.runningdata = None
async def resume(self):
if self.running:
raise ValueError("This subathon is already running!")
self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id)
async def get_score(self) -> float:
rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id)
return sum(row.score for row in rows)
def get_score_time(self, score: float) -> int:
# Get time contributed by this score
return int(score * self.subathondata.score_time)
async def get_duration(self) -> int:
"""
Number of seconds that this subathon has run for.
Includes current running session if it exists.
If the subathon is already finished (in overtime)
returns the earned time instead of the true duration.
"""
duration = self.subathondata.duration
# Add running duration if required
if self.runningdata:
now = utc_now()
added = int( (now - self.runningdata.last_started).total_seconds() )
duration += added
earned = await self.get_earned()
return min(duration, earned)
async def get_earned(self) -> int:
"""
Number of seconds earned in the subathon so far.
Includes initial time.
Takes into account the cap.
"""
score = await self.get_score()
time_earned = self.get_score_time(score)
total_time = self.subathondata.initial_time + time_earned
if cap := self.subathondata.timecap:
total_time = min(total_time, cap)
return total_time
async def get_remaining(self) -> int:
"""
Number of seconds remaining on the subathon timer.
Will be 0 if finished.
"""
total_time = await self.get_earned()
return total_time - await self.get_duration()
async def get_ending(self):
"""
Ending time of the subathon.
May be in the past if the subathon has already ended.
"""
now = utc_now()
remaining = await self.get_remaining()
return now + timedelta(seconds=remaining)
async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution:
return await SubathonContribution.create(
subathon_id=self.subathondata.subathon_id,
profileid=profileid, score=score, event_id=event_id
)
def fetch_contributions(self, **kwargs):
query = SubathonContribution.fetch_where(
subathon_id=self.subathondata.subathon_id,
**kwargs
).order_by('created_at')
return query
async def get_goals(self) -> list[SubathonGoal]:
goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score')
return goals
class SubathonRegistry:
def __init__(self, data: SubathonData, profiler: ProfilesRegistry):
self.data = data
self.profiler = profiler
async def init(self):
await self.data.init()
async def get_active_subathon(self, communityid: int) -> ActiveSubathon | None:
rows = await Subathon.fetch_where(communityid=communityid, ended_at=None)
if rows:
subathondata = rows[0]
running = await RunningSubathon.fetch(subathondata.subathon_id)
subba = ActiveSubathon(subathondata, running)
return subba