wired PICK SKY server-side polarity countdown via threading.Timer (tasks.py); fixed polarity_done overlay gating on refresh; cleared sig-select floats on overlay dismiss; filtered Redact events from Most Recent applet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,5 +78,17 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def sig_reserved(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def countdown_start(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def countdown_cancel(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def polarity_room_done(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def pick_sky_available(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def cursor_move(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
@@ -8,7 +8,14 @@ var SigSelect = (function () {
|
||||
var overlay, deckGrid, stage, stageCard, statBlock;
|
||||
var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl;
|
||||
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
|
||||
var reserveUrl, userRole, userPolarity;
|
||||
var reserveUrl, readyUrl, userRole, userPolarity;
|
||||
|
||||
var _isReady = false;
|
||||
var _takeSigBtn = null;
|
||||
var _glowTimer = null;
|
||||
var _glowPeak = false;
|
||||
var _countdownTimer = null;
|
||||
var _countdownSecondsLeft = 0;
|
||||
|
||||
var _cautionData = [];
|
||||
var _cautionIdx = 0;
|
||||
@@ -236,6 +243,7 @@ var SigSelect = (function () {
|
||||
updateStage(cardEl);
|
||||
_stageFrozen = true;
|
||||
stage.classList.add('sig-stage--frozen');
|
||||
_showTakeSigBtn();
|
||||
}
|
||||
// Thumbs-up float for all reservations — own role sees their own indicator too
|
||||
_placeReservedFloat(cardId, cardEl, role);
|
||||
@@ -246,6 +254,7 @@ var SigSelect = (function () {
|
||||
_reservedCardId = null;
|
||||
_stageFrozen = false;
|
||||
stage.classList.remove('sig-stage--frozen');
|
||||
_hideTakeSigBtn();
|
||||
}
|
||||
// Remove thumbs-up float for all releases — own role included
|
||||
if (_reservedFloats[role]) {
|
||||
@@ -309,6 +318,232 @@ var SigSelect = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reposition floats after resize ────────────────────────────────────
|
||||
//
|
||||
// Both reserved (thumbs-up) and hover (hand-pointer) floats are stamped with
|
||||
// fixed pixel coords at placement time. When the viewport changes size the
|
||||
// cards reflow but the icons stay put. Re-measure from the card's current
|
||||
// bounding rect and update left/top in-place.
|
||||
|
||||
var _posClasses = ['--left', '--mid', '--right'];
|
||||
var _xFractions = [0.15, 0.5, 0.85];
|
||||
|
||||
function _repositionFloats() {
|
||||
if (!deckGrid) return;
|
||||
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||
|
||||
Object.keys(_reservedFloats).forEach(function (role) {
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-reserved-by="' + role + '"]');
|
||||
if (!cardEl) return;
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var idx = roles.indexOf(role);
|
||||
var fc = _reservedFloats[role];
|
||||
fc.style.left = (rect.left + rect.width * _xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
});
|
||||
|
||||
Object.keys(_floatingCursors).forEach(function (key) {
|
||||
var posClass = _posClasses.find(function (p) {
|
||||
return key.slice(-p.length) === p;
|
||||
});
|
||||
if (!posClass) return;
|
||||
var cardId = key.slice(0, key.length - posClass.length);
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var idx = _posClasses.indexOf(posClass);
|
||||
var fc = _floatingCursors[key];
|
||||
fc.style.left = (rect.left + rect.width * _xFractions[idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// ── TAKE SIG / WAIT NVM button ─────────────────────────────────────────
|
||||
|
||||
function _onTakeSigClick() {
|
||||
if (_isReady) {
|
||||
var body = 'action=unready';
|
||||
if (_countdownTimer !== null) {
|
||||
body += '&seconds_remaining=' + _countdownSecondsLeft;
|
||||
}
|
||||
fetch(readyUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: body,
|
||||
}).then(function (res) {
|
||||
if (res.ok) {
|
||||
_isReady = false;
|
||||
if (_countdownTimer !== null) {
|
||||
clearInterval(_countdownTimer);
|
||||
_countdownTimer = null;
|
||||
if (_takeSigBtn) _takeSigBtn.style.fontSize = '';
|
||||
}
|
||||
if (_takeSigBtn) _takeSigBtn.textContent = 'TAKE SIG';
|
||||
_stopWaitNoGlow();
|
||||
_stopCountdownGlow();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fetch(readyUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=ready',
|
||||
}).then(function (res) {
|
||||
if (res.ok) {
|
||||
_isReady = true;
|
||||
// countdown_start WS may arrive before this response for the
|
||||
// gamer who triggered the countdown — don't clobber the numeral.
|
||||
if (_countdownTimer === null) {
|
||||
if (_takeSigBtn) _takeSigBtn.textContent = 'WAIT NVM';
|
||||
_startWaitNoGlow();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _showTakeSigBtn() {
|
||||
if (_takeSigBtn || !stage) return;
|
||||
_takeSigBtn = document.createElement('button');
|
||||
_takeSigBtn.id = 'id_take_sig_btn';
|
||||
_takeSigBtn.className = 'btn btn-primary sig-take-sig-btn';
|
||||
_takeSigBtn.type = 'button';
|
||||
_takeSigBtn.textContent = 'TAKE SIG';
|
||||
_takeSigBtn.addEventListener('click', _onTakeSigClick);
|
||||
stage.appendChild(_takeSigBtn);
|
||||
}
|
||||
|
||||
function _startWaitNoGlow() {
|
||||
if (_glowTimer !== null) return;
|
||||
_glowPeak = false;
|
||||
_glowTimer = setInterval(function () {
|
||||
if (!_takeSigBtn) { _stopWaitNoGlow(); return; }
|
||||
_glowPeak = !_glowPeak;
|
||||
if (_glowPeak) {
|
||||
_takeSigBtn.classList.add('btn-cancel');
|
||||
_takeSigBtn.style.boxShadow =
|
||||
'0 0 0.8rem 0.2rem rgba(var(--terOr), 0.75), ' +
|
||||
'0 0 2rem 0.4rem rgba(var(--terOr), 0.35)';
|
||||
} else {
|
||||
_takeSigBtn.classList.remove('btn-cancel');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function _stopWaitNoGlow() {
|
||||
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.classList.remove('btn-cancel');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
_glowPeak = false;
|
||||
}
|
||||
|
||||
function _startCountdownGlow() {
|
||||
if (_glowTimer !== null) return;
|
||||
_glowPeak = false;
|
||||
_glowTimer = setInterval(function () {
|
||||
if (!_takeSigBtn) { _stopCountdownGlow(); return; }
|
||||
_glowPeak = !_glowPeak;
|
||||
if (_glowPeak) {
|
||||
_takeSigBtn.classList.add('btn-danger');
|
||||
_takeSigBtn.style.boxShadow =
|
||||
'0 0 0.8rem 0.2rem rgba(var(--terRd), 0.75), ' +
|
||||
'0 0 2rem 0.4rem rgba(var(--terRd), 0.35)';
|
||||
} else {
|
||||
_takeSigBtn.classList.remove('btn-danger');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function _stopCountdownGlow() {
|
||||
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.classList.remove('btn-danger');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
_glowPeak = false;
|
||||
}
|
||||
|
||||
function _hideTakeSigBtn() {
|
||||
if (!_takeSigBtn) return;
|
||||
_stopWaitNoGlow();
|
||||
_takeSigBtn.removeEventListener('click', _onTakeSigClick);
|
||||
_takeSigBtn.remove();
|
||||
_takeSigBtn = null;
|
||||
_isReady = false;
|
||||
}
|
||||
|
||||
// ── Polarity countdown ────────────────────────────────────────────────
|
||||
|
||||
function _showCountdown(seconds) {
|
||||
_countdownSecondsLeft = seconds;
|
||||
_stopWaitNoGlow();
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.textContent = _countdownSecondsLeft;
|
||||
_takeSigBtn.style.fontSize = '2em';
|
||||
}
|
||||
_startCountdownGlow();
|
||||
if (_countdownTimer !== null) clearInterval(_countdownTimer);
|
||||
_countdownTimer = setInterval(function () {
|
||||
_countdownSecondsLeft -= 1;
|
||||
if (_takeSigBtn) _takeSigBtn.textContent = _countdownSecondsLeft;
|
||||
if (_countdownSecondsLeft <= 0) {
|
||||
clearInterval(_countdownTimer);
|
||||
_countdownTimer = null;
|
||||
_stopCountdownGlow(); // server drives the transition via Celery task
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function _hideCountdown() {
|
||||
if (_countdownTimer !== null) {
|
||||
clearInterval(_countdownTimer);
|
||||
_countdownTimer = null;
|
||||
}
|
||||
_stopCountdownGlow();
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.style.fontSize = '';
|
||||
if (_isReady) {
|
||||
// Countdown cancelled by another gamer — restore WAIT NVM state
|
||||
_takeSigBtn.textContent = 'WAIT NVM';
|
||||
_startWaitNoGlow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overlay dismiss + waiting message ─────────────────────────────────
|
||||
|
||||
function _dismissSigOverlay() {
|
||||
_hideCountdown();
|
||||
_hideTakeSigBtn();
|
||||
var backdrop = document.querySelector('.sig-backdrop');
|
||||
if (backdrop) backdrop.remove();
|
||||
if (overlay) { overlay.remove(); overlay = null; }
|
||||
// Remove all floating cursors (hover + thumbs-up) from the portal
|
||||
Object.keys(_reservedFloats).forEach(function (role) {
|
||||
_reservedFloats[role].remove();
|
||||
});
|
||||
_reservedFloats = {};
|
||||
Object.keys(_floatingCursors).forEach(function (key) {
|
||||
_floatingCursors[key].remove();
|
||||
});
|
||||
_floatingCursors = {};
|
||||
}
|
||||
|
||||
function _showWaitingMsg(pendingPolarity) {
|
||||
if (document.getElementById('id_hex_waiting_msg')) return;
|
||||
var msg = document.createElement('p');
|
||||
msg.id = 'id_hex_waiting_msg';
|
||||
msg.textContent = pendingPolarity === 'gravity'
|
||||
? 'Gravity settling . . .'
|
||||
: 'Levity appraising . . .';
|
||||
var center = document.querySelector('.table-center');
|
||||
if (center) center.appendChild(msg);
|
||||
}
|
||||
|
||||
// ── WS events ─────────────────────────────────────────────────────────
|
||||
|
||||
window.addEventListener('room:sig_reserved', function (e) {
|
||||
@@ -321,6 +556,33 @@ var SigSelect = (function () {
|
||||
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
||||
});
|
||||
|
||||
window.addEventListener('room:countdown_start', function (e) {
|
||||
if (!overlay) return;
|
||||
_showCountdown(e.detail.seconds);
|
||||
});
|
||||
|
||||
window.addEventListener('room:countdown_cancel', function (e) {
|
||||
_hideCountdown();
|
||||
_countdownSecondsLeft = e.detail.seconds_remaining;
|
||||
});
|
||||
|
||||
window.addEventListener('room:polarity_room_done', function (e) {
|
||||
if (!overlay) return;
|
||||
if (e.detail.polarity !== userPolarity) return;
|
||||
var pendingPolarity = userPolarity === 'levity' ? 'gravity' : 'levity';
|
||||
_dismissSigOverlay();
|
||||
_showWaitingMsg(pendingPolarity);
|
||||
});
|
||||
|
||||
window.addEventListener('room:pick_sky_available', function () {
|
||||
var msg = document.getElementById('id_hex_waiting_msg');
|
||||
if (msg) msg.remove();
|
||||
var btn = document.getElementById('id_pick_sky_btn');
|
||||
if (btn) btn.style.display = '';
|
||||
});
|
||||
|
||||
window.addEventListener('resize:end', _repositionFloats);
|
||||
|
||||
// ── WS send ───────────────────────────────────────────────────────────
|
||||
|
||||
function sendHover(cardId, active) {
|
||||
@@ -376,9 +638,19 @@ var SigSelect = (function () {
|
||||
});
|
||||
|
||||
reserveUrl = overlay.dataset.reserveUrl;
|
||||
readyUrl = overlay.dataset.readyUrl;
|
||||
|
||||
userRole = overlay.dataset.userRole;
|
||||
userPolarity= overlay.dataset.polarity;
|
||||
|
||||
// PICK SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available
|
||||
var pickSkyBtn = document.getElementById('id_pick_sky_btn');
|
||||
if (pickSkyBtn) {
|
||||
pickSkyBtn.addEventListener('click', function () {
|
||||
if (typeof Tray !== 'undefined') Tray.open();
|
||||
});
|
||||
}
|
||||
|
||||
// Restore reservations from server-rendered JSON (page-load state).
|
||||
// Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered
|
||||
// in room.js before this script) has already applied paddingBottom and
|
||||
@@ -390,6 +662,12 @@ var SigSelect = (function () {
|
||||
Object.keys(existing).forEach(function (cardId) {
|
||||
applyReservation(cardId, existing[cardId], true);
|
||||
});
|
||||
// Restore WAIT NVM state if gamer was already ready before page load
|
||||
if (overlay.dataset.ready === 'true' && _takeSigBtn) {
|
||||
_isReady = true;
|
||||
_takeSigBtn.textContent = 'WAIT NVM';
|
||||
_startWaitNoGlow();
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'complete') {
|
||||
_replayReservations();
|
||||
@@ -469,6 +747,11 @@ var SigSelect = (function () {
|
||||
Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); });
|
||||
_reservedFloats = {};
|
||||
_cursorPortal = null;
|
||||
_isReady = false;
|
||||
_stopWaitNoGlow();
|
||||
_hideTakeSigBtn();
|
||||
_hideCountdown();
|
||||
_countdownSecondsLeft = 0;
|
||||
init();
|
||||
},
|
||||
_setFrozen: function (v) { _stageFrozen = v; },
|
||||
|
||||
95
src/apps/epic/tasks.py
Normal file
95
src/apps/epic/tasks.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Countdown scheduler for the polarity-room TAKE 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))
|
||||
@@ -1605,8 +1605,10 @@ class PickSkyRenderingTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "tray-sig-card")
|
||||
|
||||
def test_pick_sky_btn_absent_during_sig_select(self):
|
||||
def test_pick_sky_btn_hidden_during_sig_select(self):
|
||||
# Rendered hidden (display:none) so JS can reveal it on pick_sky_available WS event
|
||||
self.room.table_status = Room.SIG_SELECT
|
||||
self.room.save()
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(response, "id_pick_sky_btn")
|
||||
self.assertContains(response, 'id="id_pick_sky_btn"')
|
||||
self.assertContains(response, 'style="display:none"')
|
||||
|
||||
@@ -301,10 +301,24 @@ def _role_select_context(room, user):
|
||||
elif user_role in _GRAVITY_ROLES:
|
||||
user_polarity = 'gravity'
|
||||
|
||||
user_reservation = SigReservation.objects.filter(
|
||||
room=room, gamer=user
|
||||
).first() if user.is_authenticated else None
|
||||
ctx["user_seat"] = user_seat
|
||||
ctx["user_polarity"] = user_polarity
|
||||
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
|
||||
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
||||
|
||||
# Has this gamer's polarity already had significators assigned?
|
||||
# (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
|
||||
if user_polarity:
|
||||
_polarity_roles = _LEVITY_ROLES if user_polarity == 'levity' else _GRAVITY_ROLES
|
||||
ctx["polarity_done"] = not room.table_seats.filter(
|
||||
role__in=_polarity_roles, significator__isnull=True
|
||||
).exists()
|
||||
else:
|
||||
ctx["polarity_done"] = False
|
||||
|
||||
# Pre-load existing reservations for this polarity so JS can restore
|
||||
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
|
||||
if user_polarity:
|
||||
@@ -643,6 +657,21 @@ def sig_reserve(request, room_id):
|
||||
if action == "release":
|
||||
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||
released_card_id = existing.card_id if existing else None
|
||||
if existing and existing.ready:
|
||||
# Gamer released while ready — treat as an implicit WAIT NVM
|
||||
prior = room.events.filter(
|
||||
actor=request.user, verb=GameEvent.SIG_READY
|
||||
).last()
|
||||
if prior and not prior.data.get("retracted"):
|
||||
prior.data["retracted"] = True
|
||||
prior.save(update_fields=["data"])
|
||||
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||
polarity = existing.polarity
|
||||
all_ready = SigReservation.objects.filter(
|
||||
room=room, polarity=polarity, ready=True
|
||||
).count() == 3
|
||||
if all_ready:
|
||||
_notify_countdown_cancel(room_id, polarity, seconds_remaining=12)
|
||||
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
||||
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
|
||||
return HttpResponse(status=200)
|
||||
@@ -699,8 +728,31 @@ def sig_ready(request, room_id):
|
||||
if action == "ready":
|
||||
if reservation is None:
|
||||
return HttpResponse(status=400)
|
||||
if reservation.ready:
|
||||
return HttpResponse(status=200) # idempotent — already ready, don't re-trigger countdown
|
||||
reservation.ready = True
|
||||
reservation.save(update_fields=["ready"])
|
||||
card = reservation.card
|
||||
if card and card.arcana == TarotCard.MIDDLE:
|
||||
_pol_prefix = "Leavened" if reservation.polarity == SigReservation.LEVITY else "Graven"
|
||||
_card_display = f"{_pol_prefix} {card.name_title}"
|
||||
elif card and card.arcana == TarotCard.MAJOR:
|
||||
_base = card.name_title.removeprefix("The ")
|
||||
_pol_suffix = "of Light" if reservation.polarity == SigReservation.LEVITY else "from the Grave"
|
||||
_card_display = f"{_base} {_pol_suffix}"
|
||||
else:
|
||||
_card_display = card.name_title if card else "a card"
|
||||
record(room, GameEvent.SIG_READY, actor=request.user,
|
||||
card_name=_card_display,
|
||||
corner_rank=card.corner_rank if card else "",
|
||||
suit_icon=card.suit_icon if card else "")
|
||||
# Retract the most recent un-retracted SIG_UNREADY (cancellation is now moot)
|
||||
prior_unready = room.events.filter(
|
||||
actor=request.user, verb=GameEvent.SIG_UNREADY
|
||||
).last()
|
||||
if prior_unready and not prior_unready.data.get("retracted"):
|
||||
prior_unready.data["retracted"] = True
|
||||
prior_unready.save(update_fields=["data"])
|
||||
|
||||
# Check if all three in this polarity are now ready
|
||||
polarity = reservation.polarity
|
||||
@@ -709,6 +761,7 @@ def sig_ready(request, room_id):
|
||||
room=room, polarity=polarity, ready=True
|
||||
).count()
|
||||
if ready_count == 3:
|
||||
from apps.epic.tasks import schedule_polarity_confirm
|
||||
# Use saved countdown_remaining if a pause was recorded, else 12
|
||||
saved = SigReservation.objects.filter(
|
||||
room=room, polarity=polarity
|
||||
@@ -716,12 +769,21 @@ def sig_ready(request, room_id):
|
||||
"countdown_remaining", flat=True
|
||||
).first()
|
||||
seconds = saved if saved is not None else 12
|
||||
schedule_polarity_confirm(str(room_id), polarity, seconds)
|
||||
_notify_countdown_start(room_id, polarity, seconds=seconds)
|
||||
|
||||
else: # unready
|
||||
if reservation is not None:
|
||||
reservation.ready = False
|
||||
reservation.save(update_fields=["ready"])
|
||||
# Mark the most recent un-retracted SIG_READY event for this actor
|
||||
prior = room.events.filter(
|
||||
actor=request.user, verb=GameEvent.SIG_READY
|
||||
).last()
|
||||
if prior and not prior.data.get("retracted"):
|
||||
prior.data["retracted"] = True
|
||||
prior.save(update_fields=["data"])
|
||||
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||
polarity = reservation.polarity
|
||||
|
||||
# Save remaining seconds on all polarity reservations
|
||||
@@ -733,61 +795,15 @@ def sig_ready(request, room_id):
|
||||
countdown_remaining=seconds_remaining
|
||||
)
|
||||
_notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
|
||||
from apps.epic.tasks import cancel_polarity_confirm
|
||||
cancel_polarity_confirm(str(room_id), polarity)
|
||||
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
@login_required
|
||||
def sig_confirm(request, room_id):
|
||||
"""Client posts this when the polarity-room countdown reaches zero.
|
||||
POST body: polarity=levity|gravity
|
||||
Sets significators on the three seats and broadcasts polarity_room_done.
|
||||
When both polarities are confirmed, broadcasts pick_sky_available and
|
||||
transitions the room to SKY_SELECT.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
room = Room.objects.get(id=room_id)
|
||||
if room.table_status != Room.SIG_SELECT:
|
||||
return HttpResponse(status=400)
|
||||
user_seat = _canonical_user_seat(room, request.user)
|
||||
if user_seat is None:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
seat_polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
|
||||
polarity = request.POST.get("polarity", seat_polarity)
|
||||
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||
|
||||
# Idempotency: if all seats in this polarity already have significators, skip
|
||||
already_done = not room.table_seats.filter(
|
||||
role__in=polarity_roles, significator__isnull=True
|
||||
).exists()
|
||||
if already_done:
|
||||
return HttpResponse(status=200)
|
||||
|
||||
# Guard: all three must be ready
|
||||
ready_reservations = list(
|
||||
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
||||
.select_related("seat", "card")
|
||||
)
|
||||
if len(ready_reservations) < 3:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
# Set significators from reservations
|
||||
for res in ready_reservations:
|
||||
if res.seat:
|
||||
res.seat.significator = res.card
|
||||
res.seat.save(update_fields=["significator"])
|
||||
|
||||
_notify_polarity_room_done(room_id, polarity)
|
||||
|
||||
# Check if both polarities are now confirmed
|
||||
all_done = not room.table_seats.filter(significator__isnull=True).exists()
|
||||
if all_done:
|
||||
room.table_status = Room.SKY_SELECT
|
||||
room.save(update_fields=["table_status"])
|
||||
_notify_pick_sky_available(room_id)
|
||||
|
||||
"""No-op: polarity confirmation is now driven server-side by threading.Timer in tasks.py."""
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user