feat: Add data to channel payload.

Add entire goal list to payload.
Add contributions leaderboard to payload.
Add raw sub point total to payload.
Add raw bits total to payload.

Add subpoint and bits total computations to ActiveSubathon.
This commit is contained in:
2025-10-30 21:13:17 +10:00
parent 32b84625df
commit aa87fd6f9f
2 changed files with 216 additions and 55 deletions

View File

@@ -4,12 +4,19 @@ from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dataclasses import dataclass from dataclasses import dataclass
from data.queries import JOINTYPE, ORDER
from meta.sockets import Channel from meta.sockets import Channel
from utils.lib import utc_now from utils.lib import utc_now
from modules.profiles.profiles.profiles import ProfilesRegistry from modules.profiles.profiles.profiles import ProfilesRegistry
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 .subathon import ActiveSubathon, SubathonRegistry
@@ -31,6 +38,12 @@ class ContributionPayload(TypedDict):
timestamp: ISOTimestamp timestamp: ISOTimestamp
class LeaderboardItemPayload(TypedDict):
user_name: str
user_id: str | None
amount: float
class ScoreTablePayload(TypedDict): class ScoreTablePayload(TypedDict):
bit_score: float # points per bit bit_score: float # points per bit
t1_score: float # points per T1 sub t1_score: float # points per T1 sub
@@ -47,31 +60,45 @@ class SubathonPayload(TypedDict):
name: str name: str
total_contribution: float total_contribution: float
total_subpoints: int
total_bits: int
goals_met: int goals_met: int
goals_total: int goals_total: int
last_goal: GoalPayload | None last_goal: GoalPayload | None
next_goal: GoalPayload | None next_goal: GoalPayload | None
goals: list[GoalPayload]
last_contribution: ContributionPayload | None last_contribution: ContributionPayload | None
leaderboard: list[LeaderboardItemPayload]
async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon) -> SubathonPayload: async def prepare_subathon(
now = utc_now() profiler: ProfilesRegistry, subathon: ActiveSubathon
) -> SubathonPayload:
total_score = await subathon.get_score() total_score = await subathon.get_score()
# Build the goal data
goals = await subathon.get_goals() goals = await subathon.get_goals()
goals_met = 0 goals_met = 0
last_goal = None last_goalp = None
next_goal = None next_goalp = None
goalps = []
total_goals = len(goals) total_goals = len(goals)
for goal in goals: for goal in goals:
goalp = GoalPayload(
required=float(goal.required_score),
name=goal.description,
)
goalps.append(goalp)
if goal.required_score >= total_score: if goal.required_score >= total_score:
last_goal = goal last_goalp = goalp
goals_met += 1 goals_met += 1
else: else:
next_goal = goal next_goalp = goalp
break break
# Build the last contribution information
contribs = await subathon.fetch_contributions().limit(1) contribs = await subathon.fetch_contributions().limit(1)
last_contrib: ContributionPayload | None = None last_contrib: ContributionPayload | None = None
if contribs: if contribs:
@@ -82,16 +109,17 @@ async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon)
name = profile.nickname name = profile.nickname
user_id = None user_id = None
else: else:
name = 'Anonymous' name = "Anonymous"
user_id = None user_id = None
last_contrib = ContributionPayload( last_contrib = ContributionPayload(
user_name=name, user_name=name,
user_id=user_id, user_id=user_id,
amount=float(contrib.score), amount=float(contrib.score),
seconds_added=subathon.get_score_time(contrib.score), seconds_added=subathon.get_score_time(contrib.score),
timestamp=contrib.created_at.isoformat() timestamp=contrib.created_at.isoformat(),
) )
# Build the score table
score_table = ScoreTablePayload( score_table = ScoreTablePayload(
bit_score=float(subathon.subathondata.bit_score), bit_score=float(subathon.subathondata.bit_score),
t1_score=float(subathon.subathondata.sub1_score), t1_score=float(subathon.subathondata.sub1_score),
@@ -99,41 +127,75 @@ async def prepare_subathon(profiler: ProfilesRegistry, subathon: ActiveSubathon)
t3_score=float(subathon.subathondata.sub3_score), t3_score=float(subathon.subathondata.sub3_score),
score_time=int(subathon.subathondata.score_time), score_time=int(subathon.subathondata.score_time),
) )
# Build the contribution leaderboard
query = SubathonContribution.table.select_where(
subathon_id=subathon.subathondata.subathon_id
)
query.join("user_profiles", using=("profileid",), join_type=JOINTYPE.LEFT)
query.select("subathon_id", "profileid", total="SUM(score)")
query.order_by("total", direction=ORDER.DESC)
query.group_by("subathon_id", "profileid")
query.with_no_adapter()
results = await query
leaderboard = []
for row in results:
if pid := row["profileid"]:
profile = await profiler.get_profile(pid)
name = (profile.nickname if profile else None) or "Unknown"
else:
name = "Anonymous"
score = row["total"]
item = LeaderboardItemPayload(
user_name=name,
user_id=pid,
amount=float(score),
)
leaderboard.append(item)
# Build the raw totals
subpoints = await subathon.get_total_subs()
bits = await subathon.get_total_bits()
# Finally, put together the payload
payload = SubathonPayload( payload = SubathonPayload(
name=subathon.subathondata.name, name=subathon.subathondata.name,
end_at=(await subathon.get_ending()).isoformat(), end_at=(await subathon.get_ending()).isoformat(),
is_running=(subathon.running), is_running=(subathon.running),
score_table=score_table, score_table=score_table,
total_contribution=float(await subathon.get_score()), total_contribution=float(await subathon.get_score()),
total_subpoints=subpoints,
total_bits=bits,
goals_met=goals_met, goals_met=goals_met,
goals_total=total_goals, goals_total=total_goals,
last_goal=GoalPayload( last_goal=last_goalp,
required=float(last_goal.required_score), next_goal=next_goalp,
name=last_goal.description, goals=goalps,
) if last_goal is not None else None, last_contribution=last_contrib,
next_goal=GoalPayload( leaderboard=leaderboard,
required=float(next_goal.required_score),
name=next_goal.description,
) if next_goal is not None else None,
last_contribution=last_contrib
) )
return payload return payload
class TimerChannel(Channel): class TimerChannel(Channel):
name = 'SubTimer' name = "SubTimer"
def __init__(self, profiler: ProfilesRegistry, subathons: SubathonRegistry, **kwargs): def __init__(
self, profiler: ProfilesRegistry, subathons: SubathonRegistry, **kwargs
):
super().__init__(**kwargs) super().__init__(**kwargs)
self.profiler: ProfilesRegistry = profiler self.profiler: ProfilesRegistry = profiler
self.subathons: SubathonRegistry = subathons self.subathons: SubathonRegistry = subathons
# Map of communities to webhooks listening for this community # Map of communities to webhooks listening for this community
self.communities = defaultdict(set) # Map of communityid -> listening websockets self.communities = defaultdict(
set
) # Map of communityid -> listening websockets
async def on_connection(self, websocket, event): async def on_connection(self, websocket, event):
if not (cidstr := event.get('community')): if not (cidstr := event.get("community")):
raise ValueError("Subathon timer connection missing communityid") raise ValueError("Subathon timer connection missing communityid")
elif not cidstr.isdigit(): elif not cidstr.isdigit():
raise ValueError("Community id provided is not an integer") raise ValueError("Community id provided is not an integer")
@@ -157,35 +219,39 @@ class TimerChannel(Channel):
wss.discard(websocket) wss.discard(websocket)
await super().del_connection(websocket) await super().del_connection(websocket)
async def send_subathon_update(self, communityid: int, payload: SubathonPayload, websocket=None): async def send_subathon_update(
self, communityid: int, payload: SubathonPayload, websocket=None
):
for ws in (websocket,) if websocket else self.communities[communityid]: for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event( await self.send_event(
{ {
'type': "DO", "type": "DO",
'method': "setTimer", "method": "setTimer",
'args': payload, "args": payload,
}, },
websocket=ws websocket=ws,
) )
async def send_subathon_ended(self, communityid: int, payload: SubathonPayload, websocket=None): async def send_subathon_ended(
self, communityid: int, payload: SubathonPayload, websocket=None
):
for ws in (websocket,) if websocket else self.communities[communityid]: for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event( await self.send_event(
{ {
'type': "DO", "type": "DO",
'method': "endTimer", "method": "endTimer",
'args': payload, "args": payload,
}, },
websocket=ws websocket=ws,
) )
async def send_no_subathon(self, communityid: int, websocket=None): async def send_no_subathon(self, communityid: int, websocket=None):
for ws in (websocket,) if websocket else self.communities[communityid]: for ws in (websocket,) if websocket else self.communities[communityid]:
await self.send_event( await self.send_event(
{ {
'type': "DO", "type": "DO",
'method': "noTimer", "method": "noTimer",
'args': {}, "args": {},
}, },
websocket=ws websocket=ws,
) )

View File

@@ -1,10 +1,18 @@
from asyncio import Event from asyncio import Event
from datetime import datetime, timedelta from datetime import datetime, timedelta
from data.queries import JOINTYPE
from utils.lib import utc_now, strfdelta from utils.lib import utc_now, strfdelta
from modules.profiles.profiles.profiles import ProfilesRegistry from modules.profiles.profiles.profiles import ProfilesRegistry
from . import logger from . import logger
from .data import SubathonData, Subathon, RunningSubathon, SubathonContribution, SubathonGoal from .data import (
SubathonData,
Subathon,
RunningSubathon,
SubathonContribution,
SubathonGoal,
)
class ActiveSubathon: class ActiveSubathon:
def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None): def __init__(self, subathondata: Subathon, runningdata: RunningSubathon | None):
@@ -17,7 +25,7 @@ class ActiveSubathon:
@property @property
def name(self): def name(self):
return self.subathondata.name or 'Subathon' return self.subathondata.name or "Subathon"
async def check_cap(self): async def check_cap(self):
if not (cap := self.subathondata.timecap): if not (cap := self.subathondata.timecap):
@@ -32,7 +40,7 @@ class ActiveSubathon:
""" """
Return True if the subathon duration has exceeded its earned time. Return True if the subathon duration has exceeded its earned time.
""" """
return (await self.get_remaining() <= 0) return await self.get_remaining() <= 0
async def pause(self): async def pause(self):
""" """
@@ -53,10 +61,14 @@ class ActiveSubathon:
async def resume(self): async def resume(self):
if self.running: if self.running:
raise ValueError("This subathon is already running!") raise ValueError("This subathon is already running!")
self.runningdata = await RunningSubathon.create(subathon_id=self.subathondata.subathon_id) self.runningdata = await RunningSubathon.create(
subathon_id=self.subathondata.subathon_id
)
async def get_score(self) -> float: async def get_score(self) -> float:
rows = await SubathonContribution.fetch_where(subathon_id=self.subathondata.subathon_id) rows = await SubathonContribution.fetch_where(
subathon_id=self.subathondata.subathon_id
)
return sum(row.score for row in rows) return sum(row.score for row in rows)
def get_score_time(self, score: float) -> int: def get_score_time(self, score: float) -> int:
@@ -75,7 +87,7 @@ class ActiveSubathon:
# Add running duration if required # Add running duration if required
if self.runningdata: if self.runningdata:
now = utc_now() now = utc_now()
added = int( (now - self.runningdata.last_started).total_seconds() ) added = int((now - self.runningdata.last_started).total_seconds())
duration += added duration += added
earned = await self.get_earned() earned = await self.get_earned()
@@ -111,23 +123,108 @@ class ActiveSubathon:
remaining = await self.get_remaining() remaining = await self.get_remaining()
return now + timedelta(seconds=remaining) return now + timedelta(seconds=remaining)
async def add_contribution(self, profileid: int | None, score: float, event_id: int | None) -> SubathonContribution: async def add_contribution(
self, profileid: int | None, score: float, event_id: int | None
) -> SubathonContribution:
return await SubathonContribution.create( return await SubathonContribution.create(
subathon_id=self.subathondata.subathon_id, subathon_id=self.subathondata.subathon_id,
profileid=profileid, score=score, event_id=event_id profileid=profileid,
score=score,
event_id=event_id,
) )
def fetch_contributions(self, **kwargs): def fetch_contributions(self, **kwargs):
query = SubathonContribution.fetch_where( query = SubathonContribution.fetch_where(
subathon_id=self.subathondata.subathon_id, subathon_id=self.subathondata.subathon_id, **kwargs
**kwargs ).order_by("created_at")
).order_by('created_at')
return query return query
async def get_goals(self) -> list[SubathonGoal]: async def get_goals(self) -> list[SubathonGoal]:
goals = await SubathonGoal.fetch_where(subathon_id=self.subathondata.subathon_id).order_by('required_score') goals = await SubathonGoal.fetch_where(
subathon_id=self.subathondata.subathon_id
).order_by("required_score")
return goals return goals
async def get_total_subs(self) -> int:
"""
Get the total number of sub points
contributed to this subathon.
"""
pointcol = (
"CASE "
"WHEN tier = 1000 THEN 1 "
"WHEN tier = 2000 THEN 2 "
"WHEN tier = 3000 THEN 6 "
"END"
)
total_points = 0
result = await (
SubathonContribution.table.select_where(
subathoid=self.subathondata.subathon_id
)
.join(
"subscribe_events",
using=("eventid",),
join_type=JOINTYPE.INNER,
)
.select(total=f"SUM({pointcol})")
.with_no_adapter()
)
total_points += result["total"]
result = await (
SubathonContribution.table.select_where(
subathoid=self.subathondata.subathon_id
)
.join(
"subscribe_message_events",
using=("eventid",),
join_type=JOINTYPE.INNER,
)
.select(total=f"SUM({pointcol})")
.with_no_adapter()
)
total_points += result["total"]
result = await (
SubathonContribution.table.select_where(
subathoid=self.subathondata.subathon_id
)
.join(
"subscribe_gift_events",
using=("eventid",),
join_type=JOINTYPE.INNER,
)
.select(total=f"SUM(gifted_count * ({pointcol}))")
.with_no_adapter()
)
total_points += result["total"]
return total_points
async def get_total_bits(self) -> int:
"""
Get the total number of bits
contributed to this subathon.
"""
result = await (
SubathonContribution.table.select_where(
subathoid=self.subathondata.subathon_id
)
.join(
"bits_events",
using=("eventid",),
join_type=JOINTYPE.INNER,
)
.select(total="SUM(bits)")
.with_no_adapter()
)
return result["total"]
class SubathonRegistry: class SubathonRegistry:
def __init__(self, data: SubathonData, profiler: ProfilesRegistry): def __init__(self, data: SubathonData, profiler: ProfilesRegistry):
@@ -144,5 +241,3 @@ class SubathonRegistry:
running = await RunningSubathon.fetch(subathondata.subathon_id) running = await RunningSubathon.fetch(subathondata.subathon_id)
subba = ActiveSubathon(subathondata, running) subba = ActiveSubathon(subathondata, running)
return subba return subba