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
-