Improve subathon modularity.
This commit is contained in:
191
subathon/channel.py
Normal file
191
subathon/channel.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
await payload.broadcaster.send_message(
|
if not await active.check_finished():
|
||||||
"Paused the subathon timer because the stream went offline!",
|
await payload.broadcaster.send_message(
|
||||||
sender=self.bot.bot_id
|
"Paused the subathon timer because the stream went offline!",
|
||||||
)
|
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"
|
||||||
|
|
||||||
|
dursecs = await active.get_duration()
|
||||||
|
duration = strfdelta(timedelta(seconds=dursecs))
|
||||||
|
|
||||||
secs = await active.get_remaining()
|
secs = await active.get_remaining()
|
||||||
remaining = strfdelta(timedelta(seconds=secs))
|
if secs >= 0:
|
||||||
|
remaining = strfdelta(timedelta(seconds=secs))
|
||||||
|
|
||||||
secs = active.get_duration()
|
text = (
|
||||||
duration = strfdelta(timedelta(seconds=secs))
|
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}!"
|
||||||
|
)
|
||||||
|
|
||||||
text = (
|
|
||||||
f"{active.name} running for {duration}! {score} points recieved, {goalstr}, and {remaining} left on the timer"
|
|
||||||
)
|
|
||||||
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
148
subathon/subathon.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user