diff --git a/CLAUDE.md b/CLAUDE.md index 830275b..c9e3cca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,19 @@ python src/manage.py test src/functional_tests python src/manage.py test src ``` +### Multi-user manual testing — `setup_sig_session` +`src/functional_tests/management/commands/setup_sig_session.py` + +Creates (or reuses) a room with all 6 gate slots filled, roles assigned, and `table_status=SIG_SELECT`. Prints one pre-auth URL per gamer for pasting into 6 Firefox Multi-Account Container tabs. + +```bash +python src/manage.py setup_sig_session +python src/manage.py setup_sig_session --base-url http://localhost:8000 +python src/manage.py setup_sig_session --room # reuse existing room +``` + +Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all created as superusers with Earthman deck equipped. URLs use `/lyric/dev-login//` pre-auth pattern. + **Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer. - Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels` - Channels tests only: `python src/manage.py test src/apps --tag=channels` diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index e7784f9..17b2b40 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -27,7 +27,13 @@ def billboard(request): .first() ) recent_events = ( - list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1] + list( + recent_room.events + .select_related("actor") + .exclude(verb=GameEvent.SIG_UNREADY) + .exclude(verb=GameEvent.SIG_READY, data__retracted=True) + .order_by("-timestamp")[:36] + )[::-1] if recent_room else [] ) diff --git a/src/apps/drama/migrations/0003_alter_gameevent_verb.py b/src/apps/drama/migrations/0003_alter_gameevent_verb.py new file mode 100644 index 0000000..001300f --- /dev/null +++ b/src/apps/drama/migrations/0003_alter_gameevent_verb.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-04-12 23:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('drama', '0002_scrollposition'), + ] + + operations = [ + migrations.AlterField( + model_name='gameevent', + name='verb', + field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn')], max_length=30), + ), + ] diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 0da1ba1..1a36ec5 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -1,6 +1,12 @@ from django.conf import settings from django.db import models +# ── Default gender-neutral pronouns (Baltimore original) ────────────────────── +# Later: replace with per-actor lookup when User model gains a pronouns field. +PRONOUN_SUBJ = "yo" +PRONOUN_OBJ = "yo" +PRONOUN_POSS = "yos" + class GameEvent(models.Model): # Gate phase @@ -14,6 +20,9 @@ class GameEvent(models.Model): ROLE_SELECT_STARTED = "role_select_started" ROLE_SELECTED = "role_selected" ROLES_REVEALED = "roles_revealed" + # Sig Select phase + SIG_READY = "sig_ready" + SIG_UNREADY = "sig_unready" VERB_CHOICES = [ (ROOM_CREATED, "Room created"), @@ -25,6 +34,8 @@ class GameEvent(models.Model): (ROLE_SELECT_STARTED, "Role select started"), (ROLE_SELECTED, "Role selected"), (ROLES_REVEALED, "Roles revealed"), + (SIG_READY, "Sig claim staked"), + (SIG_UNREADY, "Sig claim withdrawn"), ] room = models.ForeignKey( @@ -71,13 +82,36 @@ class GameEvent(models.Model): "PC": "Player", "BC": "Builder", "SC": "Shepherd", "AC": "Alchemist", "NC": "Narrator", "EC": "Economist", } + _chair_order = ["PC", "NC", "EC", "SC", "AC", "BC"] + _ordinals = ["1st", "2nd", "3rd", "4th", "5th", "6th"] code = d.get("role", "?") role = d.get("role_display") or _role_names.get(code, code) - return f"elects to start as the {role}, and will enjoy affinity with this Role for the remainder of the game." + try: + ordinal = _ordinals[_chair_order.index(code)] + except ValueError: + ordinal = "?" + return f"assumes {ordinal} Chair; yo will start the game as the {role}." if self.verb == self.ROLES_REVEALED: return "All roles assigned" + if self.verb == self.SIG_READY: + card_name = d.get("card_name", "a card") + corner_rank = d.get("corner_rank", "") + suit_icon = d.get("suit_icon", "") + if corner_rank: + icon_html = f' ' if suit_icon else "" + abbrev = f" ({corner_rank}{icon_html})" + else: + abbrev = "" + return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}." + if self.verb == self.SIG_UNREADY: + return f"disembodies {PRONOUN_POSS} Significator." return self.verb + @property + def struck(self): + """True when this SIG_READY event was subsequently retracted (WAIT NVM).""" + return self.data.get("retracted", False) + def to_activity(self, base_url): """Serialise this event as an AS2 Activity dict, or None if unsupported.""" if not self.actor or not self.actor.username: diff --git a/src/apps/drama/tests/integrated/test_models.py b/src/apps/drama/tests/integrated/test_models.py index 9ff87ea..b45d7ae 100644 --- a/src/apps/drama/tests/integrated/test_models.py +++ b/src/apps/drama/tests/integrated/test_models.py @@ -40,6 +40,49 @@ class GameEventModelTest(TestCase): self.assertIn("actor@test.io", str(event)) self.assertIn(GameEvent.ROLE_SELECTED, str(event)) + # ── to_prose — ROLE_SELECTED ────────────────────────────────────────── + + def test_role_selected_prose_uses_ordinal_chair(self): + for role, ordinal in [("PC", "1st"), ("NC", "2nd"), ("EC", "3rd"), + ("SC", "4th"), ("AC", "5th"), ("BC", "6th")]: + with self.subTest(role=role): + event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, + role=role, role_display="") + self.assertIn(f"assumes {ordinal} Chair", event.to_prose()) + + def test_role_selected_prose_includes_role_name(self): + event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, + role="PC", role_display="Player") + prose = event.to_prose() + self.assertIn("Player", prose) + self.assertIn("yo will start the game", prose) + + # ── to_prose — SIG_READY ───────────────────────────────────────────── + + def test_sig_ready_prose_embodies_card_with_rank_and_icon(self): + event = record(self.room, GameEvent.SIG_READY, actor=self.user, + card_name="Maid of Brands", corner_rank="M", + suit_icon="fa-wand-sparkles") + prose = event.to_prose() + self.assertIn("embodies as yos Significator the Maid of Brands", prose) + self.assertIn("(M", prose) + self.assertIn("fa-wand-sparkles", prose) + + def test_sig_ready_prose_omits_icon_when_none(self): + event = record(self.room, GameEvent.SIG_READY, actor=self.user, + card_name="The Wanderer", corner_rank="0", suit_icon="") + prose = event.to_prose() + self.assertIn("embodies as yos Significator the The Wanderer (0)", prose) + self.assertNotIn("fa-", prose) + + def test_sig_ready_prose_degrades_without_corner_rank(self): + # Old events recorded before this change have no corner_rank key + event = record(self.room, GameEvent.SIG_READY, actor=self.user, + card_name="Maid of Brands") + prose = event.to_prose() + self.assertIn("embodies as yos Significator the Maid of Brands", prose) + self.assertNotIn("(", prose) + def test_str_without_actor_shows_system(self): event = record(self.room, GameEvent.ROLES_REVEALED) self.assertIn("system", str(event)) diff --git a/src/apps/epic/consumers.py b/src/apps/epic/consumers.py index 8241583..08f5a66 100644 --- a/src/apps/epic/consumers.py +++ b/src/apps/epic/consumers.py @@ -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) diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index ca5ae7e..1f765f1 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -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; }, diff --git a/src/apps/epic/tasks.py b/src/apps/epic/tasks.py new file mode 100644 index 0000000..9281ec7 --- /dev/null +++ b/src/apps/epic/tasks.py @@ -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)) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 7dba1cb..675c708 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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"') diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index aaf8bc9..17e80e6 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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) diff --git a/src/functional_tests/test_billboard.py b/src/functional_tests/test_billboard.py index fe2e346..095c415 100644 --- a/src/functional_tests/test_billboard.py +++ b/src/functional_tests/test_billboard.py @@ -101,7 +101,7 @@ class BillboardScrollTest(FunctionalTest): self.assertIn("deposits a Free Token for slot 2 (expires in 7 days).", scroll.text) # Role selection event is rendered as prose - self.assertIn("elects to start as the Player", scroll.text) + self.assertIn("assumes 1st Chair", scroll.text) # ------------------------------------------------------------------ # # Test 3 — current user's events are right-aligned; others' are left # @@ -354,3 +354,149 @@ class BillscrollEntryLayoutTest(FunctionalTest): # events[0] is the backdated record (oldest first, ascending order) old_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time") self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}") + + +class BillscrollGearMenuTest(FunctionalTest): + """ + FT: the billscroll page has a gear menu that filters events by label. + + Frame = all regular (non-struck) drama entries. + Redact = struck-through (retracted) entries, e.g. a WAIT NVM after TAKE SIG. + + Scenario (one gamer, Role + Sig events): + 1. Both labels checked by default — all events visible. + 2. Uncheck Redact → OK: struck entries disappear. + 3. Recheck Redact + uncheck Frame → OK: regular entries gone; struck + entries visible (but still render struck-through — they remain "redacted" + in the narrative sense). + 4. Recheck Frame → OK: all entries return. + """ + + def setUp(self): + super().setUp() + self.founder = User.objects.create(email="founder@geartest.io") + self.room = Room.objects.create(name="Gear Filter Room", owner=self.founder) + # Two Frame events — ROLE_SELECTED, non-struck + record(self.room, GameEvent.ROLE_SELECTED, actor=self.founder, + role="PC", slot_number=1, role_display="Player") + record(self.room, GameEvent.ROLE_SELECTED, actor=self.founder, + role="NC", slot_number=2, role_display="Narrator") + # Two Redact events — SIG_READY with retracted=True → event.struck is True + sig1 = record(self.room, GameEvent.SIG_READY, actor=self.founder, + card_name="The Wanderer", corner_rank="0", suit_icon="") + sig1.data["retracted"] = True + sig1.save(update_fields=["data"]) + sig2 = record(self.room, GameEvent.SIG_READY, actor=self.founder, + card_name="Maid of Brands", corner_rank="M", + suit_icon="fa-wand-sparkles") + sig2.data["retracted"] = True + sig2.save(update_fields=["data"]) + + def _go_to_scroll(self): + self.create_pre_authenticated_session("founder@geartest.io") + self.browser.get( + self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" + ) + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_drama_scroll") + ) + + def _open_gear(self): + """Click the gear btn and return the now-visible menu element.""" + gear = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + ".gear-btn[data-menu-target='id_billscroll_menu']" + ) + ) + self.browser.execute_script("arguments[0].click()", gear) + return self.wait_for( + lambda: self.browser.find_element(By.ID, "id_billscroll_menu") + ) + + def _visible_events(self, label): + """Count displayed .drama-event elements with the given data-label.""" + els = self.browser.find_elements( + By.CSS_SELECTOR, f".drama-event[data-label='{label}']" + ) + return sum(1 for e in els if e.is_displayed()) + + # ------------------------------------------------------------------ # + # Step 1 — gear menu opens; both labels present and pre-checked # + # ------------------------------------------------------------------ # + + def test_gear_menu_shows_frame_and_redact_checkboxes(self): + self._go_to_scroll() + menu = self._open_gear() + self.assertTrue(menu.is_displayed()) + frame_cb = menu.find_element(By.CSS_SELECTOR, "input[value='frame']") + redact_cb = menu.find_element(By.CSS_SELECTOR, "input[value='redact']") + self.assertTrue(frame_cb.is_selected()) + self.assertTrue(redact_cb.is_selected()) + self.assertIn("Frame", menu.text) + self.assertIn("Redact", menu.text) + + # ------------------------------------------------------------------ # + # Steps 2 – 4 — filter flow # + # ------------------------------------------------------------------ # + + def test_gear_menu_filter_flow(self): + self._go_to_scroll() + + # Step 1: all 4 events visible (2 frame + 2 redact) + self.assertEqual(self._visible_events("frame"), 2) + self.assertEqual(self._visible_events("redact"), 2) + + # Step 2: uncheck Redact → OK → struck entries disappear + menu = self._open_gear() + menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click() + menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click() + self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0)) + self.assertEqual(self._visible_events("frame"), 2) + + # Step 3: recheck Redact + uncheck Frame → OK + # Redact events re-appear (still struck-through); Frame events gone. + menu = self._open_gear() + menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click() # recheck + menu.find_element(By.CSS_SELECTOR, "input[value='frame']").click() # uncheck + menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click() + self.wait_for(lambda: self.assertEqual(self._visible_events("frame"), 0)) + self.assertEqual(self._visible_events("redact"), 2) + # Struck-through entries still carry the .struck class (visually "gone" in narrative) + redact_bodies = self.browser.find_elements( + By.CSS_SELECTOR, ".drama-event[data-label='redact'] .drama-event-body" + ) + self.assertTrue(all("struck" in b.get_attribute("class") for b in redact_bodies)) + + # Step 4: recheck Frame → OK → all events return + menu = self._open_gear() + menu.find_element(By.CSS_SELECTOR, "input[value='frame']").click() # recheck + menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click() + self.wait_for(lambda: self.assertEqual(self._visible_events("frame"), 2)) + self.assertEqual(self._visible_events("redact"), 2) + + # ------------------------------------------------------------------ # + # Persistence — filter survives a full page reload # + # ------------------------------------------------------------------ # + + def test_filter_selection_persists_across_refresh(self): + self._go_to_scroll() + + # Uncheck Redact → OK: struck entries disappear + menu = self._open_gear() + menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click() + menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click() + self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0)) + + # Hard reload — same URL, same session cookie + self.browser.refresh() + self.wait_for(lambda: self.browser.find_element(By.ID, "id_drama_scroll")) + + # Struck entries still absent after reload + self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0)) + self.assertEqual(self._visible_events("frame"), 2) + + # Gear menu still shows Redact unchecked + menu = self._open_gear() + redact_cb = menu.find_element(By.CSS_SELECTOR, "input[value='redact']") + self.assertFalse(redact_cb.is_selected()) diff --git a/src/functional_tests/test_room_sig_select.py b/src/functional_tests/test_room_sig_select.py index 112b5d5..7e8546e 100644 --- a/src/functional_tests/test_room_sig_select.py +++ b/src/functional_tests/test_room_sig_select.py @@ -372,15 +372,15 @@ class SigSelectThemeTest(FunctionalTest): self.assertEqual(corr.text, "") -# ── TAKE SIG / WAIT NO — ready gate ────────────────────────────────────────── +# ── TAKE SIG / WAIT NVM — ready gate ────────────────────────────────────────── # # TAKE SIG (.btn.btn-primary) appears at the bottom-left corner of the card # stage preview once a gamer has clicked OK on a card (SigReservation exists). -# Clicking it sets the gamer's status to ready and changes the btn to WAIT NO. -# WAIT NO cancels the ready status and reverts back to TAKE SIG. +# Clicking it sets the gamer's status to ready and changes the btn to WAIT NVM. +# WAIT NVM cancels the ready status and reverts back to TAKE SIG. # # When all three gamers in a polarity WS room are ready, a 12-second countdown -# starts. Any WAIT NO during the countdown cancels it; the saved remaining time +# starts. Any WAIT NVM during the countdown cancels it; the saved remaining time # is resumed when all three are ready again. When the countdown completes # (client POSTs sig_confirm) the polarity group returns to the table hex. # When both polarity groups have confirmed, PICK SKY btn appears in the hex @@ -390,7 +390,7 @@ class SigSelectThemeTest(FunctionalTest): class SigReadyGateTest(FunctionalTest): - """Single-browser tests for TAKE SIG / WAIT NO btn.""" + """Single-browser tests for TAKE SIG / WAIT NVM btn.""" def setUp(self): super().setUp() @@ -450,7 +450,7 @@ class SigReadyGateTest(FunctionalTest): ) self.assertIn("TAKE SIG", take_sig_btn.text.upper()) - # ── SRG3: TAKE SIG → WAIT NO ─────────────────────────────────────── # + # ── SRG3: TAKE SIG → WAIT NVM ─────────────────────────────────────── # def test_take_sig_btn_becomes_wait_no_after_click(self): room = self._setup_sig_room() @@ -467,9 +467,16 @@ class SigReadyGateTest(FunctionalTest): wait_no_btn = self.wait_for( lambda: self.browser.find_element(By.ID, "id_take_sig_btn") ) - self.assertIn("WAIT NO", wait_no_btn.text.upper()) + self.assertIn("WAIT NVM", wait_no_btn.text.upper()) - # ── SRG4: WAIT NO → TAKE SIG ─────────────────────────────────────── # + # WAIT NVM pulses a --terOr glow: btn-cancel class appears within one tick + self.wait_for( + lambda: "btn-cancel" in self.browser.find_element( + By.ID, "id_take_sig_btn" + ).get_attribute("class") + ) + + # ── SRG4: WAIT NVM → TAKE SIG ─────────────────────────────────────── # def test_wait_no_reverts_to_take_sig(self): room = self._setup_sig_room() @@ -481,8 +488,8 @@ class SigReadyGateTest(FunctionalTest): btn = self.wait_for( lambda: self.browser.find_element(By.ID, "id_take_sig_btn") ) - btn.click() # → WAIT NO - self.wait_for(lambda: "WAIT NO" in self.browser.find_element( + btn.click() # → WAIT NVM + self.wait_for(lambda: "WAIT NVM" in self.browser.find_element( By.ID, "id_take_sig_btn").text.upper() ) btn = self.browser.find_element(By.ID, "id_take_sig_btn") @@ -562,20 +569,21 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest): ) b.find_element(By.ID, "id_take_sig_btn").click() - # All three browsers should now see the countdown + # All three browsers should now see the countdown button (numeral text) for b in browsers: self.wait_for( - lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b + lambda: b.find_element(By.ID, "id_take_sig_btn").text.isdigit(), + browser=b, ) finally: for b in browsers: b.quit() - # ── SRG6: countdown disappears when WAIT NO clicked ──────────────── # + # ── SRG6: countdown disappears when WAIT NVM clicked ──────────────── # @tag("channels") def test_countdown_disappears_when_any_levity_gamer_clicks_wait_no(self): - """Any WAIT NO during the countdown cancels it for all three browsers.""" + """Any WAIT NVM during the countdown cancels it for all three browsers.""" room, emails = self._setup_sig_select_room() levity_emails = [emails[0], emails[1], emails[3]] browsers = [] @@ -600,19 +608,20 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest): ) b.find_element(By.ID, "id_take_sig_btn").click() - # Confirm countdown started for all + # Confirm countdown started for all (button text is a numeral) for b in browsers: self.wait_for( - lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b + lambda: b.find_element(By.ID, "id_take_sig_btn").text.isdigit(), + browser=b, ) - # PC clicks WAIT NO + # PC clicks the countdown button to cancel browsers[0].find_element(By.ID, "id_take_sig_btn").click() - # Countdown element should disappear for all three + # Countdown should cancel for all three (button back to WAIT NVM) for b in browsers: self.wait_for( - lambda: len(b.find_elements(By.ID, "id_sig_countdown")) == 0, + lambda: b.find_element(By.ID, "id_take_sig_btn").text == "WAIT NVM", browser=b, ) finally: @@ -666,8 +675,9 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest): b.find_element(By.ID, "id_take_sig_btn").click() # Wait for countdown to expire or be confirmed; PICK SKY appears in hex + # countdown is 12 s so use wait_for_slow (MAX_WAIT=10 is not enough) for b in browsers: - self.wait_for( + self.wait_for_slow( lambda: b.find_element(By.ID, "id_pick_sky_btn"), browser=b ) finally: diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index df5e215..3859d1b 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -8,6 +8,8 @@ describe("SigSelect", () => { data-polarity="${polarity}" data-user-role="${userRole}" data-reserve-url="/epic/room/test/sig-reserve" + data-ready-url="/epic/room/test/sig-ready" + data-reservations="${reservations.replace(/"/g, '"')}">
@@ -605,4 +607,79 @@ describe("SigSelect", () => { expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe(""); }); }); + + // ── WAIT NVM glow pulse ────────────────────────────────────────────────────── // + // + // After clicking TAKE SIG (POST ok → isReady=true) a setInterval pulses the + // button at 600ms: odd ticks add .btn-cancel + a --terOr outer box-shadow; + // even ticks remove both. Uses jasmine.clock() to advance the fake timer. + + describe("WAIT NVM glow pulse", () => { + let takeSigBtn; + + beforeEach(() => { + jasmine.clock().install(); + // Pre-reserve card 42 as PC so _showTakeSigBtn() fires during init + makeFixture({ reservations: '{"42":"PC"}' }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + async function clickTakeSig() { + takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + // Flush the fetch .then() so _startWaitNoGlow() is called + await Promise.resolve(); + } + + it("adds .btn-cancel after the first pulse tick (600 ms)", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true); + }); + + it("sets a non-empty box-shadow after the first pulse tick", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); + expect(takeSigBtn.style.boxShadow).not.toBe(""); + }); + + it("removes .btn-cancel on the second tick (even / trough)", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); // peak + jasmine.clock().tick(600); // trough + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); + }); + + it("clears box-shadow on the trough tick", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); + jasmine.clock().tick(600); + expect(takeSigBtn.style.boxShadow).toBe(""); + }); + + it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); // glow is on + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true); + + // Click again → WAIT NVM → fetch unready → _stopWaitNoGlow() + takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); + expect(takeSigBtn.style.boxShadow).toBe(""); + }); + + it("glow does not advance after being stopped", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); // peak + takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); // stop + jasmine.clock().tick(600); // would be another tick if running + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); + }); + }); }); diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index 7be4513..3a8ab00 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -83,13 +83,15 @@ #id_wallet_applet_menu { @extend %applet-menu; } #id_room_menu { @extend %applet-menu; } #id_billboard_applet_menu { @extend %applet-menu; } +#id_billscroll_menu { @extend %applet-menu; } // Page-level gear buttons — fixed to viewport bottom-right .gameboard-page, .dashboard-page, .wallet-page, .room-page, -.billboard-page { +.billboard-page, +.billscroll-page { > .gear-btn { position: fixed; bottom: 4.2rem; @@ -102,7 +104,8 @@ #id_game_applet_menu, #id_game_kit_menu, #id_wallet_applet_menu, -#id_billboard_applet_menu { +#id_billboard_applet_menu, +#id_billscroll_menu { position: fixed; bottom: 6.6rem; right: 1rem; @@ -118,7 +121,8 @@ .dashboard-page, .wallet-page, .room-page, - .billboard-page { + .billboard-page, + .billscroll-page { > .gear-btn { right: 1rem; bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed @@ -131,7 +135,8 @@ #id_game_kit_menu, #id_wallet_applet_menu, #id_room_menu, - #id_billboard_applet_menu { + #id_billboard_applet_menu, + #id_billscroll_menu { right: 1rem; bottom: 6.6rem; top: auto; @@ -144,7 +149,8 @@ .dashboard-page, .wallet-page, .room-page, - .billboard-page { + .billboard-page, + .billscroll-page { > .gear-btn { right: 2.5rem; } } @@ -153,7 +159,8 @@ #id_game_kit_menu, #id_wallet_applet_menu, #id_room_menu, - #id_billboard_applet_menu { right: 2.5rem; } + #id_billboard_applet_menu, + #id_billscroll_menu { right: 2.5rem; } } // ── Applet box visual shell (reusable outside the grid) ──── diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index ca84a6f..b98c288 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -139,6 +139,11 @@ body.page-billscroll { .drama-event-body { flex: 0 0 80%; + + &.struck { + text-decoration: line-through; + opacity: 0.5; + } } .drama-event-time { diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index f4e9886..d2dcbb8 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -546,10 +546,28 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut .table-center { display: flex; + flex-direction: column; align-items: center; justify-content: center; } +// "Gravity settling . . ." / "Levity appraising . . ." shown after a polarity +// group confirms their sigs while the other group is still selecting. +// Pulsing opacity signals active waiting without being jarring. +#id_hex_waiting_msg { + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(var(--terUser), 0.8); + text-align: center; + margin: 0.4rem 0 0; + animation: hex-wait-pulse 2.4s ease-in-out infinite; +} + +@keyframes hex-wait-pulse { + 0%, 100% { opacity: 0.75; } + 50% { opacity: 0.3; } +} + .table-seat { position: absolute; display: grid; diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index df5e215..3859d1b 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -8,6 +8,8 @@ describe("SigSelect", () => { data-polarity="${polarity}" data-user-role="${userRole}" data-reserve-url="/epic/room/test/sig-reserve" + data-ready-url="/epic/room/test/sig-ready" + data-reservations="${reservations.replace(/"/g, '"')}">
@@ -605,4 +607,79 @@ describe("SigSelect", () => { expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe(""); }); }); + + // ── WAIT NVM glow pulse ────────────────────────────────────────────────────── // + // + // After clicking TAKE SIG (POST ok → isReady=true) a setInterval pulses the + // button at 600ms: odd ticks add .btn-cancel + a --terOr outer box-shadow; + // even ticks remove both. Uses jasmine.clock() to advance the fake timer. + + describe("WAIT NVM glow pulse", () => { + let takeSigBtn; + + beforeEach(() => { + jasmine.clock().install(); + // Pre-reserve card 42 as PC so _showTakeSigBtn() fires during init + makeFixture({ reservations: '{"42":"PC"}' }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + async function clickTakeSig() { + takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + // Flush the fetch .then() so _startWaitNoGlow() is called + await Promise.resolve(); + } + + it("adds .btn-cancel after the first pulse tick (600 ms)", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true); + }); + + it("sets a non-empty box-shadow after the first pulse tick", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); + expect(takeSigBtn.style.boxShadow).not.toBe(""); + }); + + it("removes .btn-cancel on the second tick (even / trough)", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); // peak + jasmine.clock().tick(600); // trough + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); + }); + + it("clears box-shadow on the trough tick", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); + jasmine.clock().tick(600); + expect(takeSigBtn.style.boxShadow).toBe(""); + }); + + it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); // glow is on + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true); + + // Click again → WAIT NVM → fetch unready → _stopWaitNoGlow() + takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); + expect(takeSigBtn.style.boxShadow).toBe(""); + }); + + it("glow does not advance after being stopped", async () => { + await clickTakeSig(); + jasmine.clock().tick(601); // peak + takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); // stop + jasmine.clock().tick(600); // would be another tick if running + expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false); + }); + }); }); diff --git a/src/templates/apps/billboard/_partials/_applet-billboard-most-recent.html b/src/templates/apps/billboard/_partials/_applet-billboard-most-recent.html index 69628f3..850ec11 100644 --- a/src/templates/apps/billboard/_partials/_applet-billboard-most-recent.html +++ b/src/templates/apps/billboard/_partials/_applet-billboard-most-recent.html @@ -10,9 +10,9 @@ Load more…. {% for event in recent_events %}
- + {{ event.actor|display_name }} - {{ event.to_prose }} + {{ event.to_prose|safe }}
@@ -57,8 +62,8 @@
- {# Sig Select overlay — only shown to seated gamers in this polarity #} - {% if room.table_status == "SIG_SELECT" and user_polarity %} + {# Sig Select overlay — suppressed once this gamer's polarity sigs are assigned #} + {% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done %} {% include "apps/gameboard/_partials/_sig_select_overlay.html" %} {% endif %} diff --git a/src/templates/core/_partials/_scroll.html b/src/templates/core/_partials/_scroll.html index eb89c48..ec4bc03 100644 --- a/src/templates/core/_partials/_scroll.html +++ b/src/templates/core/_partials/_scroll.html @@ -1,10 +1,10 @@ {% load lyric_extras %}
{% for event in events %} -
- +
+ {{ event.actor|display_name }} - {{ event.to_prose }} + {{ event.to_prose|safe }}