Files
python-tdd/src/apps/epic/tasks.py
Disco DeDisco 3242873625 btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default .sig-stage .sig-stage-card .fan-card-face .sig-qualifier-* rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each .fan-card-reversal-* class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-<p> skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two <p>s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00

96 lines
3.0 KiB
Python

"""
Countdown scheduler for the polarity-room SAVE SIG gate.
Uses threading.Timer so no separate Celery worker is needed in development.
Single-process only — swap for a Celery task if production uses multiple
web workers (gunicorn -w N with N > 1).
"""
import threading
import uuid
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.cache import cache
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
# In-process registry of pending timers: "{room_id}_{polarity}" → Timer
_timers = {}
def _cache_key(room_id, polarity):
return f'sig_countdown_{room_id}_{polarity}'
def _group_send(room_id, msg):
async_to_sync(get_channel_layer().group_send)(f'room_{room_id}', msg)
def _fire(room_id, polarity, token):
"""Callback run by threading.Timer after the countdown expires."""
# Token guard: if cancelled or superseded, cache entry will differ
if cache.get(_cache_key(room_id, polarity)) != token:
return
from apps.epic.models import Room, SigReservation
try:
room = Room.objects.get(id=room_id)
except Room.DoesNotExist:
return
if room.table_status != Room.SIG_SELECT:
return
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
# Idempotency: seats already assigned
if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
return
# Safety: all three must still be ready
ready_reservations = list(
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
.select_related('seat', 'card')
)
if len(ready_reservations) < 3:
return
for res in ready_reservations:
if res.seat:
res.seat.significator = res.card
res.seat.save(update_fields=['significator'])
SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)
_group_send(room_id, {'type': 'polarity_room_done', 'polarity': polarity})
if not room.table_seats.filter(significator__isnull=True).exists():
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
_group_send(room_id, {'type': 'pick_sky_available'})
cache.delete(_cache_key(room_id, polarity))
_timers.pop(f'{room_id}_{polarity}', None)
def schedule_polarity_confirm(room_id, polarity, seconds):
"""Schedule a polarity confirm `seconds` seconds from now. Cancels any prior timer."""
cancel_polarity_confirm(room_id, polarity)
token = str(uuid.uuid4())
cache.set(_cache_key(room_id, polarity), token, timeout=int(seconds) + 60)
timer = threading.Timer(seconds, _fire, args=[str(room_id), polarity, token])
timer.daemon = True
timer.start()
_timers[f'{room_id}_{polarity}'] = timer
def cancel_polarity_confirm(room_id, polarity):
"""Cancel any pending confirm for this room + polarity."""
timer = _timers.pop(f'{room_id}_{polarity}', None)
if timer:
timer.cancel()
cache.delete(_cache_key(room_id, polarity))