diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..2124646 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Format Python with black before push (matches CI: . --exclude '/migrations/'). +# If black rewrites files, abort so the changes can be committed first. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +run_black() { + if command -v pipenv >/dev/null 2>&1 && [[ -f Pipfile ]]; then + pipenv run black "$@" + elif command -v black >/dev/null 2>&1; then + black "$@" + else + echo "pre-push: black not found (install black, or use pipenv with black available)" >&2 + exit 1 + fi +} + +echo "pre-push: running black..." +run_black . --exclude '/migrations/' + +if ! git diff --quiet -- '*.py'; then + echo "" >&2 + echo "pre-push: black reformatted Python files. Commit them and push again:" >&2 + git --no-pager diff --stat -- '*.py' >&2 + exit 1 +fi + +echo "pre-push: black ok" diff --git a/apps/blog/models.py b/apps/blog/models.py index dfd95fb..bfbde2e 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -361,7 +361,9 @@ def send_blogpost_webhook(self): # Only format username as a link if the user is authenticated if self.user and self.user.is_authenticated: profile_path = reverse("user-profile", args=[self.get_display_name()]) - username_display = f"[{self.get_display_name()}](<{settings.SITE_URL}{profile_path}>)" + username_display = ( + f"[{self.get_display_name()}](<{settings.SITE_URL}{profile_path}>)" + ) else: username_display = f'"{self.get_display_name()}"' diff --git a/apps/blog/views.py b/apps/blog/views.py index d184e43..312e7c0 100644 --- a/apps/blog/views.py +++ b/apps/blog/views.py @@ -431,7 +431,9 @@ def item_categories(self, item): # "Enclosure" stuff so we can use the preview images in the rss feeds def item_enclosure_url(self, item): - if getattr(item, "featured_image", None) and getattr(item.featured_image, "url", None): + if getattr(item, "featured_image", None) and getattr( + item.featured_image, "url", None + ): return self._absolute_url(item.featured_image.url) return None diff --git a/apps/bookclub/models.py b/apps/bookclub/models.py index 9a3fa30..b2d1e6f 100644 --- a/apps/bookclub/models.py +++ b/apps/bookclub/models.py @@ -4,7 +4,6 @@ from django.db import models from django.contrib.auth import get_user_model - User = get_user_model() logger = logging.getLogger(__name__) diff --git a/apps/bot_manager/models.py b/apps/bot_manager/models.py index 703a38c..caf2cb7 100644 --- a/apps/bot_manager/models.py +++ b/apps/bot_manager/models.py @@ -12,13 +12,11 @@ def get_tables(): """Get list of tables in Fops database""" with get_fops_connection() as conn: with conn.cursor() as cur: - cur.execute( - """ + cur.execute(""" SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' - """ - ) + """) return [row["table_name"] for row in cur.fetchall()] diff --git a/apps/tanks_manager/views.py b/apps/tanks_manager/views.py index 7a6d406..fbed1f6 100644 --- a/apps/tanks_manager/views.py +++ b/apps/tanks_manager/views.py @@ -140,12 +140,12 @@ def _editor_data(site): "tankBottomOffset": site.tank_bottom_offset, "character_name": site.character_name, "character_url": site.character_url, - "stage_background_url": site.stage_background.url - if site.stage_background - else "", - "stage_foreground_url": site.stage_foreground.url - if site.stage_foreground - else "", + "stage_background_url": ( + site.stage_background.url if site.stage_background else "" + ), + "stage_foreground_url": ( + site.stage_foreground.url if site.stage_foreground else "" + ), }, "logs": [ {"date": g.date, "text": g.text} diff --git a/apps/vore_day_rumble/models.py b/apps/vore_day_rumble/models.py index a04519b..f545f0c 100644 --- a/apps/vore_day_rumble/models.py +++ b/apps/vore_day_rumble/models.py @@ -100,6 +100,32 @@ class Meta: def __str__(self): return self.display_name + def prey(self): + """ + Full stack of previous prey!! >:3 Counts if you get double-ate too! + """ + latest = ( + self.matches_won.select_related("contestant_a", "contestant_b") + .order_by("-round_number", "-position") + .first() + ) + if not latest: + return [] + + if latest.winner_id == latest.contestant_a_id: + loser = latest.contestant_b + my_side, loser_side = "a", "b" + else: + loser = latest.contestant_a + my_side, loser_side = "b", "a" + + out = [] + if loser: + out.append(loser) + out.extend(latest.previous_prey(loser_side)) + out.extend(latest.previous_prey(my_side)) + return out + class Match(models.Model): """One matchup in a round. Round 1 gets rebuilt whenever someone signs up.""" @@ -168,6 +194,51 @@ def live_vote_counts(self): ) return agg["left"] or 0, agg["right"] or 0 + def previous_prey(self, side): + """ + Contestants this side already ate on the way here. + Beating someone also absorbs their prey stack. + Newest elimination first. + side: "a" or "b" + """ + if side == "a": + contestant = self.contestant_a + pos = self.position * 2 + elif side == "b": + contestant = self.contestant_b + pos = self.position * 2 + 1 + else: + raise ValueError(f"side must be 'a' or 'b', got {side!r}") + + if not contestant or self.round_number <= 1: + return [] + + out = [] + for r in range(self.round_number - 1, 0, -1): + feeder = ( + Match.objects.select_related("contestant_a", "contestant_b", "winner") + .filter(round_number=r, position=pos) + .first() + ) + if not feeder or feeder.winner_id != contestant.id: + break + + if feeder.winner_id == feeder.contestant_a_id: + loser = feeder.contestant_b + loser_side = "b" + pos = feeder.position * 2 + else: + loser = feeder.contestant_a + loser_side = "a" + pos = feeder.position * 2 + 1 + + if loser: + out.append(loser) + # Inherit everyone the loser had already eaten + out.extend(feeder.previous_prey(loser_side)) + + return out + class MatchVote(models.Model): """One anonymous site vote per snowsune account, per match.""" @@ -179,9 +250,7 @@ class MatchVote(models.Model): (CHOICE_B, "Contestant B"), ] - match = models.ForeignKey( - Match, on_delete=models.CASCADE, related_name="votes" - ) + match = models.ForeignKey(Match, on_delete=models.CASCADE, related_name="votes") user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, diff --git a/apps/vore_day_rumble/static/vore_day_rumble/css/rumble.css b/apps/vore_day_rumble/static/vore_day_rumble/css/rumble.css index dabd872..c2b6245 100644 --- a/apps/vore_day_rumble/static/vore_day_rumble/css/rumble.css +++ b/apps/vore_day_rumble/static/vore_day_rumble/css/rumble.css @@ -87,13 +87,32 @@ text-align: center; } -#rumble-bracket { +.rumble-bracket-scroll { width: 100%; + max-width: 100%; margin: 0.5rem 0 1rem; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + touch-action: pan-x pan-y; +} + +#rumble-bracket { + width: 100%; background: transparent; color: var(--text-color); } +@media (max-width: 768px) { + .rumble-bracket-scroll { + /* Full-bleed within the content column so swipes aren't cramped */ + margin-left: -0.5rem; + margin-right: -0.5rem; + padding: 0 0.5rem 0.35rem; + } +} + #rumble-bracket:empty { display: flex; align-items: center; diff --git a/apps/vore_day_rumble/static/vore_day_rumble/js/bracket.js b/apps/vore_day_rumble/static/vore_day_rumble/js/bracket.js index 941831e..db2a098 100644 --- a/apps/vore_day_rumble/static/vore_day_rumble/js/bracket.js +++ b/apps/vore_day_rumble/static/vore_day_rumble/js/bracket.js @@ -15,6 +15,14 @@ if (dataEl && wrapper) { const sidebar = css.getPropertyValue("--sidebar-bg").trim() || "#eee"; const pfpSize = 44; + const matchMaxWidth = 220; + const matchHorMargin = 16; + const roundCount = data.rounds.length; + // Keep rounds readable on narrow screens; parent .rumble-bracket-scroll pans horizontally + const minBracketWidth = roundCount * (matchMaxWidth + matchHorMargin * 2); + if (window.matchMedia("(max-width: 768px)").matches) { + wrapper.style.minWidth = `${minBracketWidth}px`; + } createBracket(data, wrapper, { width: "100%", @@ -26,6 +34,8 @@ if (dataEl && wrapper) { verticalScrollMode: "native", roundTitlesFontSize: 0, roundTitlesVerticalPadding: 0, + matchMaxWidth, + matchHorMargin, rootBgColor: tile, rootBorderColor: "transparent", wrapperBorderColor: "transparent", diff --git a/apps/vore_day_rumble/static/vore_day_rumble/js/voting.js b/apps/vore_day_rumble/static/vore_day_rumble/js/voting.js index f1eb9d2..fbfbe6e 100644 --- a/apps/vore_day_rumble/static/vore_day_rumble/js/voting.js +++ b/apps/vore_day_rumble/static/vore_day_rumble/js/voting.js @@ -3,10 +3,9 @@ if (!root) return; const voteUrl = root.dataset.voteUrl; - const statusUrl = root.dataset.statusUrl; + const streamUrl = root.dataset.streamUrl; const loggedIn = root.dataset.loggedIn === "1"; - const csrfInput = root.querySelector("[name=csrfmiddlewaretoken]"); - const csrf = csrfInput ? csrfInput.value : ""; + const csrf = root.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; const countdownEl = document.getElementById("rumble-vote-countdown"); const hintEl = document.getElementById("rumble-vote-hint"); @@ -16,18 +15,19 @@ let endsAtUnix = null; let clockSkewMs = 0; - let pollTimer = null; - let countdownTimer = null; + let source = null; function applyStatus(data) { if (!data || data.voting_open === false) { - if (countdownEl) countdownEl.textContent = "Voting closed! Refresh for the next match!"; + if (countdownEl) { + countdownEl.textContent = "Voting closed! Refresh for the next match!"; + } buttons.forEach((btn) => { btn.disabled = true; }); - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; + if (source) { + source.close(); + source = null; } return; } @@ -64,16 +64,15 @@ const s = seconds % 60; if (m >= 60) { const h = Math.floor(m / 60); - const rm = m % 60; - return `Closes in ${h}h ${rm}m`; + return `Closes in ${h}h ${m % 60}m`; } return `Closes in ${m}:${String(s).padStart(2, "0")}`; } function tickCountdown() { if (!countdownEl || endsAtUnix == null) return; - const nowUnix = Math.floor((Date.now() - clockSkewMs) / 1000); - const remaining = endsAtUnix - nowUnix; + const remaining = + endsAtUnix - Math.floor((Date.now() - clockSkewMs) / 1000); countdownEl.textContent = formatRemaining(remaining); if (remaining <= 0) { buttons.forEach((btn) => { @@ -82,20 +81,15 @@ } } - async function pollStatus() { - try { - const resp = await fetch(`${statusUrl}?_=${Date.now()}`, { - headers: { Accept: "application/json" }, - credentials: "same-origin", - cache: "no-store", - }); - if (!resp.ok) return; - const data = await resp.json(); - applyStatus(data); - tickCountdown(); - } catch (_) { - /* ignore transient poll errors */ - } + function startStream() { + if (!streamUrl || typeof EventSource === "undefined") return; + source = new EventSource(streamUrl); + source.addEventListener("status", (event) => { + try { + applyStatus(JSON.parse(event.data)); + tickCountdown(); + } catch (_) {} + }); } async function castVote(choice) { @@ -104,8 +98,7 @@ btn.disabled = true; }); try { - const body = new URLSearchParams(); - body.set("choice", choice); + const body = new URLSearchParams({ choice }); const resp = await fetch(voteUrl, { method: "POST", headers: { @@ -115,13 +108,11 @@ }, credentials: "same-origin", cache: "no-store", - body: body.toString(), + body, }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { - if (hintEl) { - hintEl.textContent = data.error || "Could not cast vote :<"; - } + if (hintEl) hintEl.textContent = data.error || "Could not cast vote :<"; buttons.forEach((btn) => { btn.disabled = false; }); @@ -145,12 +136,10 @@ if (boot) { try { applyStatus(JSON.parse(boot.textContent)); - } catch (_) { - /* ignore bad boot JSON */ - } + } catch (_) {} } tickCountdown(); - countdownTimer = setInterval(tickCountdown, 1000); - pollTimer = setInterval(pollStatus, 4000); + setInterval(tickCountdown, 1000); + startStream(); })(); diff --git a/apps/vore_day_rumble/templates/vore_day_rumble/index.html b/apps/vore_day_rumble/templates/vore_day_rumble/index.html index 8c3c18e..e8721a1 100644 --- a/apps/vore_day_rumble/templates/vore_day_rumble/index.html +++ b/apps/vore_day_rumble/templates/vore_day_rumble/index.html @@ -63,7 +63,7 @@

Vixi's Vore Day Rumble

class="rumble-now-voting" id="rumble-now-voting" data-vote-url="{% url 'vore_day_rumble:cast_vote' %}" - data-status-url="{% url 'vore_day_rumble:vote_status' %}" + data-stream-url="{% url 'vore_day_rumble:vote_stream' %}" data-logged-in="{% if request.user.is_authenticated %}1{% else %}0{% endif %}" > {% csrf_token %} @@ -122,6 +122,8 @@

Now voting

{% endif %}

Nom-nom Bracket

-
+
+
+
{% endblock %} diff --git a/apps/vore_day_rumble/urls.py b/apps/vore_day_rumble/urls.py index 6f528ff..9504219 100644 --- a/apps/vore_day_rumble/urls.py +++ b/apps/vore_day_rumble/urls.py @@ -8,5 +8,5 @@ path("", views.index, name="index"), path("enter/", views.enter, name="enter"), path("vote/", views.cast_vote, name="cast_vote"), - path("vote/status/", views.vote_status, name="vote_status"), + path("vote/stream/", views.vote_stream, name="vote_stream"), ] diff --git a/apps/vore_day_rumble/views.py b/apps/vore_day_rumble/views.py index 0737b82..44b81c9 100644 --- a/apps/vore_day_rumble/views.py +++ b/apps/vore_day_rumble/views.py @@ -2,14 +2,15 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.http import JsonResponse +from django.http import JsonResponse, StreamingHttpResponse from django.shortcuts import redirect, render from django.utils import timezone from django.views.decorators.http import require_GET, require_POST from .bracket import build_bracketry_data from .forms import ContestantEntryForm -from .models import Contestant, Match, MatchVote, RumbleSettings, reshuffle_bracket +from .models import Contestant, MatchVote, RumbleSettings, reshuffle_bracket +from .voting import live as vote_live def rumble_enabled_required(view_func): @@ -29,34 +30,9 @@ def _wrapped(request, *args, **kwargs): return _wrapped -def _vote_payload(match, user=None): - """JSON-friendly status for the live voting panel.""" - left, right = match.live_vote_counts() - my_choice = None - if user is not None and user.is_authenticated: - vote = MatchVote.objects.filter(match=match, user=user).first() - if vote: - my_choice = vote.choice - - ends_at = match.voting_ends_at - return { - "match_id": match.pk, - "voting_open": match.voting_open, - "votes_left": left, - "votes_right": right, - "my_choice": my_choice, - "ends_at": ends_at.isoformat() if ends_at else None, - "ends_at_unix": int(ends_at.timestamp()) if ends_at else None, - "server_now_unix": int(timezone.now().timestamp()), - "name_a": match.contestant_a.display_name if match.contestant_a else None, - "name_b": match.contestant_b.display_name if match.contestant_b else None, - } - - @rumble_enabled_required def index(request): """Main rumble page.""" - # Sweep any votes whose timer already elapsed from .voting.runner import resolve_due_votes try: @@ -66,15 +42,10 @@ def index(request): settings = RumbleSettings.get() contestants = Contestant.objects.select_related("user").all() - active_match = ( - Match.objects.filter(voting_open=True) - .select_related("contestant_a", "contestant_b") - .first() - ) - + active_match = vote_live.active_match() vote_status = None if active_match and active_match.contestant_a and active_match.contestant_b: - vote_status = _vote_payload(active_match, request.user) + vote_status = vote_live.snapshot_for(request.user, active_match) return render( request, @@ -92,19 +63,14 @@ def index(request): @rumble_enabled_required @require_GET -def vote_status(request): - """Live tallies + countdown for the open match (poll this).""" - match = ( - Match.objects.filter(voting_open=True) - .select_related("contestant_a", "contestant_b") - .first() +def vote_stream(request): + """SSE status feed — tallies + this viewer's my_choice.""" + response = StreamingHttpResponse( + vote_live.iter_sse(request.user), + content_type="text/event-stream", ) - if match is None: - response = JsonResponse({"voting_open": False}) - else: - response = JsonResponse(_vote_payload(match, request.user)) - # Must not be cached: tallies change constantly and are cookie-sensitive - response["Cache-Control"] = "private, no-store" + response["Cache-Control"] = "no-cache, no-store" + response["X-Accel-Buffering"] = "no" return response @@ -119,11 +85,7 @@ def cast_vote(request): if choice not in (MatchVote.CHOICE_A, MatchVote.CHOICE_B): return JsonResponse({"error": "Invalid choice"}, status=400) - match = ( - Match.objects.filter(voting_open=True) - .select_related("contestant_a", "contestant_b") - .first() - ) + match = vote_live.active_match() if match is None or not match.contestant_a or not match.contestant_b: return JsonResponse({"error": "No match is open for voting"}, status=400) @@ -141,7 +103,8 @@ def cast_vote(request): user=request.user, defaults={"choice": choice}, ) - response = JsonResponse(_vote_payload(match, request.user)) + vote_live.notify() + response = JsonResponse(vote_live.snapshot_for(request.user, match)) response["Cache-Control"] = "private, no-store" return response @@ -179,7 +142,6 @@ def enter(request): contestant = form.save(commit=False) contestant.user = request.user contestant.save() - # New signup? reshuffle. Just an update? leave the bracket alone. if existing is None: reshuffle_bracket() messages.success(request, "You're in the rumble!") diff --git a/apps/vore_day_rumble/voting/__init__.py b/apps/vore_day_rumble/voting/__init__.py index cf8f40c..29300ce 100644 --- a/apps/vore_day_rumble/voting/__init__.py +++ b/apps/vore_day_rumble/voting/__init__.py @@ -2,7 +2,8 @@ Vixi's Vore Day Rumble voting. Flow: - 1. composite.py - build the matchup / winner images - 2. discord.py - announce matchups, countdowns, and results - 3. runner.py - open a vote window / resolve from site tallies + 1. composite.py - matchup / winner images + 2. discord.py - announce matchups, countdowns, results + 3. runner.py - open / resolve a vote window + 4. live.py - SSE status (tallies + your my_choice) """ diff --git a/apps/vore_day_rumble/voting/composite.py b/apps/vore_day_rumble/voting/composite.py index abdca4c..a8a24e9 100644 --- a/apps/vore_day_rumble/voting/composite.py +++ b/apps/vore_day_rumble/voting/composite.py @@ -6,7 +6,9 @@ import io import logging +import math import os +from functools import lru_cache from pathlib import Path from django.conf import settings @@ -16,15 +18,22 @@ # Card size matches the blank backdrop CARD_SIZE = (1200, 630) -PFP_SIZE = 280 -WINNER_PFP_SIZE = 360 -LEFT_CENTER = (320, 260) -RIGHT_CENTER = (880, 260) -CENTER = (CARD_SIZE[0] // 2, 300) -NAME_Y = 470 -WINNER_NAME_Y = 530 +PFP_SIZE = 240 +WINNER_PFP_SIZE = 320 +LEFT_CENTER = (300, 200) +RIGHT_CENTER = (900, 200) +CENTER = (CARD_SIZE[0] // 2, 220) +NAME_Y = 18 +WINNER_NAME_Y = 560 NAME_COLOR = (0, 0, 0) +# Prey under each fighter: equal tiles in a grid that fits the leftover box +PREY_MAX_SIZE = 88 +PREY_MIN_SIZE = 24 +PREY_GAP = 6 +PREY_BOTTOM_PAD = 12 +PREY_MAX_WIDTH = 300 + def _backdrop_path(): return ( @@ -37,6 +46,16 @@ def _backdrop_path(): ) +def _vore_tile_path(): + return ( + Path(settings.BASE_DIR) + / "static" + / "background" + / "seasonal" + / "tile_vore_day.png" + ) + + def _font_dir(): return ( Path(settings.BASE_DIR) @@ -64,10 +83,21 @@ def _load_font(size, bold=True): return ImageFont.truetype(str(path), size) except OSError: continue - logger.warning("No TTF font found - text will be tiny. Ship fonts or install fonts-dejavu-core.") + logger.warning( + "No TTF font found - text will be tiny. Ship fonts or install fonts-dejavu-core." + ) return ImageFont.load_default() +@lru_cache(maxsize=1) +def _load_vore_tile(): + path = _vore_tile_path() + if path.exists(): + return Image.open(path).convert("RGBA") + logger.warning("Missing vore day tile at %s", path) + return Image.new("RGBA", (60, 60), (0, 0, 0, 0)) + + def _load_pfp(contestant): """Load a contestant's pfp, or a plain placeholder.""" if contestant and contestant.profile_picture and contestant.profile_picture.name: @@ -93,6 +123,41 @@ def _square_pfp(src, size=PFP_SIZE): return src.resize((size, size), Image.Resampling.LANCZOS) +def _prey_badge(contestant, size): + """PFP with the vore-day jaws stamped over it.""" + pfp = _square_pfp(_load_pfp(contestant), size=size) + jaws = _load_vore_tile().resize((size, size), Image.Resampling.LANCZOS) + badge = pfp.copy() + badge.alpha_composite(jaws) + return badge + + +def _prey_grid_dims(count): + """Pick columns/rows — short wide grids over tall stacks.""" + if count <= 0: + return 0, 0 + if count == 1: + return 1, 1 + if count == 2: + return 2, 1 + if count <= 4: + return 2, math.ceil(count / 2) + if count <= 9: + return 3, math.ceil(count / 3) + if count <= 16: + return 4, math.ceil(count / 4) + cols = math.ceil(math.sqrt(count)) + return cols, math.ceil(count / cols) + + +def _prey_cell_size(cols, rows, max_w, max_h): + if cols <= 0 or rows <= 0 or max_w <= 0 or max_h <= 0: + return PREY_MIN_SIZE + size_w = (max_w - (cols - 1) * PREY_GAP) // cols + size_h = (max_h - (rows - 1) * PREY_GAP) // rows + return max(PREY_MIN_SIZE, min(PREY_MAX_SIZE, size_w, size_h)) + + def _paste_centered(base, overlay, center): x = int(center[0] - overlay.width / 2) y = int(center[1] - overlay.height / 2) @@ -102,32 +167,68 @@ def _paste_centered(base, overlay, center): base.paste(overlay, (x, y)) +def _paste_prey_grid( + card, prey, center_x, start_y, max_width=PREY_MAX_WIDTH, max_bottom=None +): + """Draw prior prey as an equal-tile grid under a fighter.""" + if not prey: + return + + count = len(prey) + cols, rows = _prey_grid_dims(count) + bottom = CARD_SIZE[1] - PREY_BOTTOM_PAD if max_bottom is None else max_bottom + available_h = bottom - start_y + size = _prey_cell_size(cols, rows, max_width, available_h) + + for i, contestant in enumerate(prey): + row = i // cols + col = i % cols + items_in_row = min(cols, count - row * cols) + row_w = items_in_row * size + (items_in_row - 1) * PREY_GAP + row_left = center_x - row_w // 2 + x = row_left + col * (size + PREY_GAP) + size // 2 + y = start_y + row * (size + PREY_GAP) + size // 2 + _paste_centered(card, _prey_badge(contestant, size), (x, y)) + + def _draw_centered_text(draw, text, center_x, y, font, fill=(255, 255, 255)): bbox = draw.textbbox((0, 0), text, font=font) w = bbox[2] - bbox[0] draw.text((center_x - w / 2, y), text, font=font, fill=fill) -def create_matchup_image(contestant_a, contestant_b): +def _blank_card(): + backdrop_path = _backdrop_path() + if backdrop_path.exists(): + return ( + Image.open(backdrop_path) + .convert("RGBA") + .resize(CARD_SIZE, Image.Resampling.LANCZOS) + ) + return Image.new("RGBA", CARD_SIZE, (32, 32, 40, 255)) + + +def create_matchup_image(contestant_a, contestant_b, prey_a=None, prey_b=None): """ Composite two contestants onto the backdrop with names + VS. + Optional prey_a / prey_b: prior prey newest-first, drawn underneath. Returns a BytesIO PNG ready to upload. """ - backdrop_path = _backdrop_path() - if backdrop_path.exists(): - card = Image.open(backdrop_path).convert("RGBA").resize( - CARD_SIZE, Image.Resampling.LANCZOS - ) - else: - card = Image.new("RGBA", CARD_SIZE, (32, 32, 40, 255)) + prey_a = prey_a or [] + prey_b = prey_b or [] + card = _blank_card() left = _square_pfp(_load_pfp(contestant_a)) right = _square_pfp(_load_pfp(contestant_b)) _paste_centered(card, left, LEFT_CENTER) _paste_centered(card, right, RIGHT_CENTER) + trail_y = LEFT_CENTER[1] + PFP_SIZE // 2 + PREY_GAP + _paste_prey_grid(card, prey_a, LEFT_CENTER[0], trail_y) + _paste_prey_grid(card, prey_b, RIGHT_CENTER[0], trail_y) + draw = ImageDraw.Draw(card) - name_font = _load_font(52) + name_font = _load_font(48) vs_font = _load_font(100) left_name = (contestant_a.display_name if contestant_a else "-")[:28] @@ -154,21 +255,27 @@ def create_matchup_image(contestant_a, contestant_b): return buf -def create_winner_image(contestant): +def create_winner_image(contestant, prey=None): """ - Single contestant centered on the backdrop with WINNER + Single contestant centered on the backdrop with WINNER. + Optional prey: prior prey newest-first under the champ. """ - backdrop_path = _backdrop_path() - if backdrop_path.exists(): - card = Image.open(backdrop_path).convert("RGBA").resize( - CARD_SIZE, Image.Resampling.LANCZOS - ) - else: - card = Image.new("RGBA", CARD_SIZE, (32, 32, 40, 255)) + prey = prey or [] + card = _blank_card() pfp = _square_pfp(_load_pfp(contestant), size=WINNER_PFP_SIZE) _paste_centered(card, pfp, CENTER) + trail_y = CENTER[1] + WINNER_PFP_SIZE // 2 + PREY_GAP + _paste_prey_grid( + card, + prey, + CENTER[0], + trail_y, + max_width=min(PREY_MAX_WIDTH * 2, 420), + max_bottom=WINNER_NAME_Y - PREY_GAP, + ) + draw = ImageDraw.Draw(card) name_font = _load_font(56) winner_font = _load_font(96) diff --git a/apps/vore_day_rumble/voting/live.py b/apps/vore_day_rumble/voting/live.py new file mode 100644 index 0000000..0e51f9d --- /dev/null +++ b/apps/vore_day_rumble/voting/live.py @@ -0,0 +1,178 @@ +""" +Live vote status for SSE +""" + +from __future__ import annotations + +import json +import logging +import threading +from typing import Any, Iterator + +from django.db import close_old_connections +from django.utils import timezone + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_cv = threading.Condition(_lock) +_version = 0 +_snapshot: dict[str, Any] = {"voting_open": False} +_subscribers = 0 +_poller: threading.Thread | None = None + +POLL_SECONDS = 1.0 + + +def active_match(): + from ..models import Match + + return ( + Match.objects.filter(voting_open=True) + .select_related("contestant_a", "contestant_b") + .first() + ) + + +def _shared_snapshot(match=None) -> dict[str, Any]: + """Tallies + countdown (no per-user fields).""" + if match is None: + match = active_match() + if match is None or not match.contestant_a or not match.contestant_b: + return {"voting_open": False} + + left, right = match.live_vote_counts() + ends_at = match.voting_ends_at + return { + "match_id": match.pk, + "voting_open": True, + "votes_left": left, + "votes_right": right, + "ends_at_unix": int(ends_at.timestamp()) if ends_at else None, + "server_now_unix": int(timezone.now().timestamp()), + } + + +def snapshot_for(user=None, match=None, base=None) -> dict[str, Any]: + """Status payload for the UI: shared tallies + my_choice.""" + snap = dict(base) if base is not None else _shared_snapshot(match) + snap["my_choice"] = None + snap["server_now_unix"] = int(timezone.now().timestamp()) + if ( + not snap.get("voting_open") + or user is None + or not getattr(user, "is_authenticated", False) + ): + return snap + + from ..models import MatchVote + + match_id = snap.get("match_id") + if not match_id: + return snap + vote = MatchVote.objects.filter(match_id=match_id, user=user).first() + if vote: + snap["my_choice"] = vote.choice + return snap + + +def _snap_key(snap: dict[str, Any]) -> tuple: + return ( + snap.get("match_id"), + snap.get("voting_open"), + snap.get("votes_left"), + snap.get("votes_right"), + snap.get("ends_at_unix"), + ) + + +def _poller_loop() -> None: + global _poller, _version, _snapshot + last_key = None + try: + while True: + close_old_connections() + try: + snap = _shared_snapshot() + except Exception: + logger.exception("Vote live poller failed") + snap = {"voting_open": False} + + key = _snap_key(snap) + with _cv: + if key != last_key: + last_key = key + _snapshot = snap + _version += 1 + _cv.notify_all() + elif _subscribers <= 0: + _poller = None + return + _cv.wait(timeout=POLL_SECONDS) + finally: + close_old_connections() + with _lock: + if _poller is threading.current_thread(): + _poller = None + + +def subscribe() -> int: + global _subscribers, _poller + with _cv: + _subscribers += 1 + if _poller is None or not _poller.is_alive(): + _poller = threading.Thread( + target=_poller_loop, + name="vore-day-rumble-vote-live", + daemon=True, + ) + _poller.start() + _cv.notify_all() + return _version + + +def unsubscribe() -> None: + global _subscribers + with _cv: + _subscribers = max(0, _subscribers - 1) + _cv.notify_all() + + +def notify() -> None: + """Wake the poller after a vote cast / match open / resolve.""" + with _cv: + _cv.notify_all() + + +def wait_for_update( + last_version: int, timeout: float = 15.0 +) -> tuple[int, dict[str, Any]]: + with _cv: + if _version == last_version: + _cv.wait(timeout=timeout) + return _version, dict(_snapshot) + + +def iter_sse(user) -> Iterator[str]: + """SSE frames for one watcher (status events + keepalives).""" + version = subscribe() + try: + version, snap = wait_for_update(version, timeout=2.0) + while True: + payload = snapshot_for(user, base=snap) + yield ( + f"id: {version}\n" f"event: status\n" f"data: {json.dumps(payload)}\n\n" + ) + if not snap.get("voting_open"): + return + + while True: + prev = version + version, snap = wait_for_update(prev, timeout=15.0) + if version != prev: + break + yield ": keepalive\n\n" + except GeneratorExit: + return + finally: + unsubscribe() diff --git a/apps/vore_day_rumble/voting/runner.py b/apps/vore_day_rumble/voting/runner.py index 83429aa..89609bb 100644 --- a/apps/vore_day_rumble/voting/runner.py +++ b/apps/vore_day_rumble/voting/runner.py @@ -20,6 +20,7 @@ from ..models import Match, RumbleSettings, advance_winners_into_next_rounds from . import discord as discord_api from .composite import create_matchup_image, create_winner_image +from .live import notify as notify_vote_live logger = logging.getLogger(__name__) @@ -45,7 +46,12 @@ def start_vote(match): ends_unix = int(ends_at.timestamp()) vote_url = f"{settings.SITE_URL.rstrip('/')}/voreday/" - image = create_matchup_image(a, b) + image = create_matchup_image( + a, + b, + prey_a=match.previous_prey("a"), + prey_b=match.previous_prey("b"), + ) content = ( f"**Vixi's Vore Day Rumble!**\n" f"**{a.display_name}** vs **{b.display_name}**\n" @@ -76,6 +82,7 @@ def start_vote(match): ) _schedule_resolve(match.pk, match.voting_ends_at) + notify_vote_live() logger.info( "Started vote for match %s (msg %s, ends %s)", match.pk, @@ -115,11 +122,7 @@ def resolve_vote(match, force=False): if match.winner_id and not force: return match.winner - if ( - not force - and match.voting_ends_at - and match.voting_ends_at > timezone.now() - ): + if not force and match.voting_ends_at and match.voting_ends_at > timezone.now(): logger.info( "Match %s vote still running until %s", match.pk, match.voting_ends_at ) @@ -155,12 +158,11 @@ def resolve_vote(match, force=False): ] ) advance_winners_into_next_rounds() + notify_vote_live() a_name = match.contestant_a.display_name b_name = match.contestant_b.display_name - result = ( - f"**Results!** **{a_name}**: **{left}** | **{b_name}**: **{right}**\n" - ) + result = f"**Results!** **{a_name}**: **{left}** | **{b_name}**: **{right}**\n" if tie: result += f"Omg a tie! Coin flip goes to **{winner.display_name}**!~" else: @@ -228,14 +230,12 @@ def announce_champion_if_crowned(channel_id=None): return champion try: - image = create_winner_image(champion) + image = create_winner_image(champion, prey=champion.prey()) content = ( f"**The Vore Day Rumble is over!**\n" f"**{champion.display_name}** wins!~ (Uurp~)" ) - discord_api.post_matchup( - channel_id, content, image, filename="winner.png" - ) + discord_api.post_matchup(channel_id, content, image, filename="winner.png") rumble.champion_announced = True rumble.save(update_fields=["champion_announced"]) logger.info("Posted WINNER card for %s", champion.display_name) diff --git a/bin/entrypoint.sh b/bin/entrypoint.sh index 7afcb84..c069905 100755 --- a/bin/entrypoint.sh +++ b/bin/entrypoint.sh @@ -13,4 +13,11 @@ echo "0 */6 * * * /app/manage.py ensure_sitemap >> /var/log/cron.log 2>&1" | cro service cron start # Start the application -exec gunicorn snowsune.wsgi:application --bind 0.0.0.0:80 --workers 4 --timeout 120 --access-logfile - --error-logfile - \ No newline at end of file +exec gunicorn snowsune.wsgi:application \ + --bind 0.0.0.0:80 \ + --worker-class gthread \ + --workers 3 \ + --threads 25 \ + --timeout 120 \ + --access-logfile - \ + --error-logfile - \ No newline at end of file diff --git a/manage.py b/manage.py index 5ef6a9d..a36d3c1 100755 --- a/manage.py +++ b/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys diff --git a/snowsune/context_processors.py b/snowsune/context_processors.py index 68fa272..9875194 100644 --- a/snowsune/context_processors.py +++ b/snowsune/context_processors.py @@ -72,5 +72,5 @@ def seasonal_css(request): # If no exact match, check for month-only match (month,) if not css_file: css_file = seasonal_dates.get((today.month,)) - + return {"seasonal_css": f"css/seasonal/{css_file}" if css_file else None} diff --git a/snowsune/urls.py b/snowsune/urls.py index 64c889f..8962d18 100644 --- a/snowsune/urls.py +++ b/snowsune/urls.py @@ -17,7 +17,6 @@ from snowsune.views.redirects import matrix_redirect from apps.thank_yous.views import thank_you_view - urlpatterns = [ # django path("admin/", admin.site.urls), diff --git a/snowsune/views/live_status.py b/snowsune/views/live_status.py index 43a25f0..c3ef164 100644 --- a/snowsune/views/live_status.py +++ b/snowsune/views/live_status.py @@ -51,10 +51,15 @@ def get_server_offset(): try: resp = requests.get( f"{ha_url_setting.value}/api/states/sensor.server_offset_percentage", - headers={"Authorization": f"Bearer {ha_token_setting.value}", "Content-Type": "application/json"}, + headers={ + "Authorization": f"Bearer {ha_token_setting.value}", + "Content-Type": "application/json", + }, timeout=5, ) - value = resp.json().get("state", "Unknown") if resp.status_code == 200 else "Error" + value = ( + resp.json().get("state", "Unknown") if resp.status_code == 200 else "Error" + ) except Exception as e: logger.info("Home Assistant server offset unavailable: %s", e) value = "Offline" @@ -81,6 +86,7 @@ def live_status_view(request): ) response["Cache-Control"] = "public, max-age=30" response["Vary"] = "Cookie" - response["Expires"] = (timezone.now() + timedelta(seconds=30)).strftime("%a, %d %b %Y %H:%M:%S GMT") + response["Expires"] = (timezone.now() + timedelta(seconds=30)).strftime( + "%a, %d %b %Y %H:%M:%S GMT" + ) return response -