From c7370bda03981917c18c8b7b277f1a308f1c4783 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 5 Apr 2026 22:01:23 -0400 Subject: [PATCH] =?UTF-8?q?sig-select=20sprint:=20SigReservation=20model?= =?UTF-8?q?=20+=20sig=5Freserve=20view=20(OK/NVM=20hold);=20full=20sig-sel?= =?UTF-8?q?ect.js=20rewrite=20with=20stage=20preview,=20WS=20hover=20curso?= =?UTF-8?q?rs,=20reservation=20lock=20(must=20NVM=20before=20OK-ing=20anot?= =?UTF-8?q?her=20card=20=E2=80=94=20enforced=20server-side=20409=20+=20JS?= =?UTF-8?q?=20guard);=20sizeSigModal()=20+=20sizeSigCard()=20in=20room.js?= =?UTF-8?q?=20(JS-based=20card=20sizing=20avoids=20libsass=20cqw/cqh=20lim?= =?UTF-8?q?itation);=20stat=20block=20hidden=20until=20OK=20pressed;=20mob?= =?UTF-8?q?ile=20touch:=20dismiss=20stage=20on=20outside-grid=20tap=20when?= =?UTF-8?q?=20unfocused;=2017=20IT=20+=20Jasmine=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/apps/epic/consumers.py | 19 +- .../epic/migrations/0022_sig_reservation.py | 31 ++ src/apps/epic/models.py | 67 ++++ src/apps/epic/static/apps/epic/room.js | 57 ++++ src/apps/epic/static/apps/epic/sig-select.js | 310 ++++++++++++++---- .../epic/tests/integrated/test_consumers.py | 95 ++++++ src/apps/epic/tests/integrated/test_models.py | 123 ++++++- src/apps/epic/tests/integrated/test_views.py | 159 ++++++++- src/apps/epic/urls.py | 1 + src/apps/epic/views.py | 111 ++++++- src/static/tests/SigSelectSpec.js | 215 ++++++++++++ src/static/tests/SpecRunner.html | 2 + src/static_src/scss/_base.scss | 2 +- src/static_src/scss/_room.scss | 275 +++++++++++++--- src/static_src/tests/SigSelectSpec.js | 215 ++++++++++++ src/static_src/tests/SpecRunner.html | 2 + .../_partials/_sig_select_overlay.html | 64 ++++ src/templates/apps/gameboard/room.html | 40 +-- 18 files changed, 1616 insertions(+), 172 deletions(-) create mode 100644 src/apps/epic/migrations/0022_sig_reservation.py create mode 100644 src/static/tests/SigSelectSpec.js create mode 100644 src/static_src/tests/SigSelectSpec.js create mode 100644 src/templates/apps/gameboard/_partials/_sig_select_overlay.html diff --git a/src/apps/epic/consumers.py b/src/apps/epic/consumers.py index 44e5000..8241583 100644 --- a/src/apps/epic/consumers.py +++ b/src/apps/epic/consumers.py @@ -32,11 +32,22 @@ class RoomConsumer(AsyncJsonWebsocketConsumer): await self.channel_layer.group_discard(self.cursor_group, self.channel_name) async def receive_json(self, content): - if content.get("type") == "cursor_move" and self.cursor_group: + msg_type = content.get("type") + if msg_type == "cursor_move" and self.cursor_group: await self.channel_layer.group_send( self.cursor_group, {"type": "cursor_move", "x": content.get("x"), "y": content.get("y")}, ) + elif msg_type == "sig_hover" and self.cursor_group: + await self.channel_layer.group_send( + self.cursor_group, + { + "type": "sig_hover", + "card_id": content.get("card_id"), + "role": content.get("role"), + "active": content.get("active"), + }, + ) @database_sync_to_async def _get_seat(self, user): @@ -61,5 +72,11 @@ class RoomConsumer(AsyncJsonWebsocketConsumer): async def sig_selected(self, event): await self.send_json(event) + async def sig_hover(self, event): + await self.send_json(event) + + async def sig_reserved(self, event): + await self.send_json(event) + async def cursor_move(self, event): await self.send_json(event) diff --git a/src/apps/epic/migrations/0022_sig_reservation.py b/src/apps/epic/migrations/0022_sig_reservation.py new file mode 100644 index 0000000..fc4af3d --- /dev/null +++ b/src/apps/epic/migrations/0022_sig_reservation.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0 on 2026-04-06 00:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0021_rename_earthman_major_arcana_batch_2'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SigReservation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(max_length=2)), + ('polarity', models.CharField(choices=[('levity', 'Levity'), ('gravity', 'Gravity')], max_length=7)), + ('reserved_at', models.DateTimeField(auto_now_add=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.tarotcard')), + ('gamer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to=settings.AUTH_USER_MODEL)), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.room')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('room', 'gamer'), name='one_sig_reservation_per_gamer_per_room'), models.UniqueConstraint(fields=('room', 'card', 'polarity'), name='one_reservation_per_card_per_polarity_per_room')], + }, + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index c60cf4f..ba30262 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -3,6 +3,7 @@ import uuid from datetime import timedelta from django.db import models +from django.db.models import UniqueConstraint from django.db.models.signals import post_save from django.dispatch import receiver from django.conf import settings @@ -324,6 +325,37 @@ class TarotDeck(models.Model): self.save(update_fields=["drawn_card_ids"]) +# ── SigReservation — provisional card hold during SIG_SELECT ────────────────── + +class SigReservation(models.Model): + LEVITY = 'levity' + GRAVITY = 'gravity' + POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')] + + room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations') + gamer = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations' + ) + card = models.ForeignKey( + 'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations' + ) + role = models.CharField(max_length=2) + polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES) + reserved_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + UniqueConstraint( + fields=['room', 'gamer'], + name='one_sig_reservation_per_gamer_per_room', + ), + UniqueConstraint( + fields=['room', 'card', 'polarity'], + name='one_reservation_per_card_per_polarity_per_room', + ), + ] + + # ── Significator deck helpers ───────────────────────────────────────────────── def sig_deck_cards(room): @@ -358,6 +390,41 @@ def sig_deck_cards(room): return unique_cards + unique_cards # × 2 = 36 +def _sig_unique_cards(room): + """Return the 18 unique TarotCard objects that form one sig pile.""" + deck_variant = room.owner.equipped_deck + if deck_variant is None: + return [] + wands_pentacles = list(TarotCard.objects.filter( + deck_variant=deck_variant, + arcana=TarotCard.MINOR, + suit__in=[TarotCard.WANDS, TarotCard.PENTACLES], + number__in=[11, 12, 13, 14], + )) + swords_cups = list(TarotCard.objects.filter( + deck_variant=deck_variant, + arcana=TarotCard.MINOR, + suit__in=[TarotCard.SWORDS, TarotCard.CUPS], + number__in=[11, 12, 13, 14], + )) + major = list(TarotCard.objects.filter( + deck_variant=deck_variant, + arcana=TarotCard.MAJOR, + number__in=[0, 1], + )) + return wands_pentacles + swords_cups + major + + +def levity_sig_cards(room): + """The 18 cards available to the levity group (PC/NC/SC).""" + return _sig_unique_cards(room) + + +def gravity_sig_cards(room): + """The 18 cards available to the gravity group (BC/EC/AC).""" + return _sig_unique_cards(room) + + def sig_seat_order(room): """Return TableSeats in canonical PC→NC→EC→SC→AC→BC order.""" _order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)} diff --git a/src/apps/epic/static/apps/epic/room.js b/src/apps/epic/static/apps/epic/room.js index ca8c511..8d63733 100644 --- a/src/apps/epic/static/apps/epic/room.js +++ b/src/apps/epic/static/apps/epic/room.js @@ -20,6 +20,62 @@ window.addEventListener('resize', scaleTable); }()); +(function () { + // Size the sig-select overlay so the card grid clears the tray handle + // (portrait: right strip 48px; landscape: bottom strip 48px) and any + // fixed gear/kit buttons that protrude further into the viewport. + // Mirrors the scaleTable() pattern — runs on load (after tray.js has + // positioned the tray) and on every resize. + function sizeSigModal() { + var overlay = document.querySelector('.sig-overlay'); + if (!overlay) return; + + var vw = window.innerWidth; + var vh = window.innerHeight; + var rightInset = 0; + var bottomInset = 0; + + // Tray handle: portrait → vertical strip on right; landscape → horizontal at bottom + var trayHandle = document.getElementById('id_tray_handle'); + if (trayHandle) { + var hr = trayHandle.getBoundingClientRect(); + if (hr.width >= hr.height) { + // Landscape: handle spans the bottom + bottomInset = vh - hr.top; + } else { + // Portrait: handle strips the right edge + rightInset = vw - hr.left; + } + } + + // Gear / kit buttons fixed at the right edge may protrude left of the tray handle + document.querySelectorAll('.room-page > .gear-btn').forEach(function (btn) { + var br = btn.getBoundingClientRect(); + if (br.right > vw - 30) { + rightInset = Math.max(rightInset, vw - br.left); + } + }); + + overlay.style.paddingRight = rightInset + 'px'; + overlay.style.paddingBottom = bottomInset + 'px'; + + // Stage card: smaller of 40% stage width OR (80% stage height × 5/8 aspect). + // libsass can't handle cqw/cqh inside min(), so we compute it here. + var stageEl = overlay.querySelector('.sig-stage'); + if (stageEl) { + var sw = stageEl.offsetWidth - 24; // subtract padding (0.75rem × 2) + var sh = stageEl.offsetHeight - 24; + if (sw > 0 && sh > 0) { + var cardW = Math.min(sw * 0.4, sh * 0.8 * 5 / 8); + overlay.style.setProperty('--sig-card-w', cardW + 'px'); + } + } + } + + window.addEventListener('load', sizeSigModal); + window.addEventListener('resize', sizeSigModal); +}()); + (function () { const roomPage = document.querySelector('.room-page'); if (!roomPage) return; @@ -27,6 +83,7 @@ const roomId = roomPage.dataset.roomId; const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`); + window._roomSocket = ws; // exposed for sig-select.js hover broadcast ws.onmessage = function (event) { const data = JSON.parse(event.data); diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index b368f6f..999825e 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -1,96 +1,272 @@ var SigSelect = (function () { - var SIG_ORDER = ['PC', 'NC', 'EC', 'SC', 'AC', 'BC']; + // Polarity → three roles in fixed left/mid/right cursor order + var POLARITY_ROLES = { + levity: ['PC', 'NC', 'SC'], + gravity: ['BC', 'EC', 'AC'], + }; - var sigDeck, selectUrl, userRole; + var overlay, deckGrid, stage, stageCard; + var reserveUrl, userRole, userPolarity; - function getActiveRole() { - for (var i = 0; i < SIG_ORDER.length; i++) { - var seat = document.querySelector('.table-seat[data-role="' + SIG_ORDER[i] + '"]'); - if (seat && !seat.dataset.sigDone) return SIG_ORDER[i]; - } - return null; - } - - function isEligible() { - return !!(userRole && userRole === getActiveRole()); - } + var _focusedCardEl = null; // card currently shown in stage + var _reservedCardId = null; // card with active reservation + var _stageFrozen = false; // true after OK — stage locks on reserved card + var _requestInFlight = false; function getCsrf() { var m = document.cookie.match(/csrftoken=([^;]+)/); return m ? m[1] : ''; } - function applySelection(cardId, role, deckType) { - // Remove only the specific pile copy (levity or gravity) of this card - var selector = '.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]'; - sigDeck.querySelectorAll(selector).forEach(function (c) { c.remove(); }); + // ── Stage ────────────────────────────────────────────────────────────── - // Mark this seat done, remove active - var seat = document.querySelector('.table-seat[data-role="' + role + '"]'); - if (seat) { - seat.classList.remove('active'); - seat.dataset.sigDone = '1'; + function updateStage(cardEl) { + if (_stageFrozen) return; + if (!cardEl) { + stageCard.style.display = 'none'; + stage.classList.remove('sig-stage--active'); + _focusedCardEl = null; + return; } + _focusedCardEl = cardEl; - // Advance active to next seat - var nextRole = getActiveRole(); - if (nextRole) { - var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]'); - if (nextSeat) nextSeat.classList.add('active'); - } + var rank = cardEl.dataset.cornerRank || ''; + var icon = cardEl.dataset.suitIcon || ''; + var group = cardEl.dataset.nameGroup || ''; + var title = cardEl.dataset.nameTitle || ''; + var arcana= cardEl.dataset.arcana || ''; + var corr = cardEl.dataset.correspondence || ''; - // Place a card placeholder in inventory - var invSlot = document.getElementById('id_inv_sig_card'); - if (invSlot) { - var card = document.createElement('div'); - card.className = 'card'; - invSlot.appendChild(card); + stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; }); + stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) { + if (icon) { + el.className = 'fa-solid ' + icon + ' stage-suit-icon'; + el.style.display = ''; + } else { + el.style.display = 'none'; + } + }); + stageCard.querySelector('.fan-card-name-group').textContent = group; + stageCard.querySelector('.fan-card-name').textContent = title; + stageCard.querySelector('.fan-card-arcana').textContent = arcana; + stageCard.querySelector('.fan-card-correspondence').textContent = corr; + + stageCard.style.display = ''; + stage.classList.add('sig-stage--active'); + } + + // ── Focus a card (click/tap) — shows OK overlay on the card ────────── + + function focusCard(cardEl) { + deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) { + if (c !== cardEl) c.classList.remove('sig-focused'); + }); + cardEl.classList.add('sig-focused'); + updateStage(cardEl); + } + + // ── Hover events ────────────────────────────────────────────────────── + + function onCardEnter(e) { + var card = e.currentTarget; + if (!_stageFrozen) updateStage(card); + sendHover(card.dataset.cardId, true); + } + + function onCardLeave(e) { + if (!_stageFrozen) updateStage(null); + sendHover(e.currentTarget.dataset.cardId, false); + } + + // ── Reserve / release ───────────────────────────────────────────────── + + function doReserve(cardEl) { + if (_requestInFlight) return; + var cardId = cardEl.dataset.cardId; + _requestInFlight = true; + fetch(reserveUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() }, + body: 'action=reserve&card_id=' + encodeURIComponent(cardId), + }).then(function (res) { + _requestInFlight = false; + if (res.ok) applyReservation(cardId, userRole, true); + }).catch(function () { _requestInFlight = false; }); + } + + function doRelease() { + if (_requestInFlight || !_reservedCardId) return; + var cardId = _reservedCardId; + _requestInFlight = true; + fetch(reserveUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() }, + body: 'action=release&card_id=' + encodeURIComponent(cardId), + }).then(function (res) { + _requestInFlight = false; + if (res.ok) applyReservation(cardId, userRole, false); + }).catch(function () { _requestInFlight = false; }); + } + + // ── Apply reservation state (local + from WS) ───────────────────────── + + function applyReservation(cardId, role, reserved) { + var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]'); + if (!cardEl) return; + + if (reserved) { + cardEl.dataset.reservedBy = role; + cardEl.classList.add('sig-reserved'); + if (role === userRole) { + _reservedCardId = cardId; + cardEl.classList.add('sig-reserved--own'); + cardEl.classList.remove('sig-focused'); + // Freeze stage on this card (temporarily unfreeze to populate it) + _stageFrozen = false; + updateStage(cardEl); + _stageFrozen = true; + stage.classList.add('sig-stage--frozen'); + } + } else { + delete cardEl.dataset.reservedBy; + cardEl.classList.remove('sig-reserved', 'sig-reserved--own'); + if (role === userRole) { + _reservedCardId = null; + _stageFrozen = false; + stage.classList.remove('sig-stage--frozen'); + } } } + // ── Apply hover cursor (WS only — own hover is CSS :hover) ──────────── + + function applyHover(cardId, role, active) { + if (role === userRole) return; + var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]'); + if (!cardEl) return; + + var roles = POLARITY_ROLES[userPolarity] || []; + var idx = roles.indexOf(role); + var posClass = ['--left', '--mid', '--right'][idx] || '--left'; + var cursor = cardEl.querySelector('.sig-cursor' + posClass); + if (!cursor) return; + + if (active) { + cursor.classList.add('active'); + } else { + cursor.classList.remove('active'); + } + } + + // ── WS events ───────────────────────────────────────────────────────── + + window.addEventListener('room:sig_reserved', function (e) { + if (!deckGrid) return; + applyReservation(String(e.detail.card_id), e.detail.role, e.detail.reserved); + }); + + window.addEventListener('room:sig_hover', function (e) { + if (!deckGrid) return; + applyHover(String(e.detail.card_id), e.detail.role, e.detail.active); + }); + + // ── WS send ─────────────────────────────────────────────────────────── + + function sendHover(cardId, active) { + if (!window._roomSocket || window._roomSocket.readyState !== WebSocket.OPEN) return; + window._roomSocket.send(JSON.stringify({ + type: 'sig_hover', card_id: cardId, role: userRole, active: active, + })); + } + + // ── Init ────────────────────────────────────────────────────────────── + function init() { - sigDeck = document.getElementById('id_sig_deck'); - if (!sigDeck) return; - selectUrl = sigDeck.dataset.selectSigUrl; - userRole = sigDeck.dataset.userRole; + overlay = document.querySelector('.sig-overlay'); + if (!overlay) return; - sigDeck.addEventListener('click', function (e) { + deckGrid = overlay.querySelector('.sig-deck-grid'); + stage = overlay.querySelector('.sig-stage'); + stageCard = stage.querySelector('.sig-stage-card'); + + reserveUrl = overlay.dataset.reserveUrl; + userRole = overlay.dataset.userRole; + userPolarity= overlay.dataset.polarity; + + // Restore reservations from server-rendered JSON (page-load state) + try { + var existing = JSON.parse(overlay.dataset.reservations || '{}'); + Object.keys(existing).forEach(function (cardId) { + applyReservation(cardId, existing[cardId], true); + }); + } catch (e) { /* malformed JSON — ignore */ } + + // Hover: update stage preview + broadcast cursor + deckGrid.querySelectorAll('.sig-card').forEach(function (card) { + card.addEventListener('mouseenter', onCardEnter); + card.addEventListener('mouseleave', onCardLeave); + card.addEventListener('touchstart', function (e) { + var card = e.currentTarget; + if (_reservedCardId) return; // locked until NVM — no preventDefault either + var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole; + var isOwnReserved = card.classList.contains('sig-reserved--own'); + if (reservedByOther || isOwnReserved) return; + // If the tap is on the OK button, let the synthetic click fire normally + if (e.target.closest('.sig-ok-btn')) return; + focusCard(card); + e.preventDefault(); // prevent ghost click on card body + }, { passive: false }); + }); + + // Touch outside the grid — dismiss stage preview (unfocused state only). + // Card touchstart doesn't stop propagation, so we guard with closest(). + overlay.addEventListener('touchstart', function (e) { + if (_stageFrozen || !_focusedCardEl) return; + if (e.target.closest('.sig-deck-grid')) return; + deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) { + c.classList.remove('sig-focused'); + }); + updateStage(null); + }, { passive: true }); + + // Click delegation: card body → focus (shows OK); OK/NVM buttons → reserve/release + deckGrid.addEventListener('click', function (e) { + if (e.target.closest('.sig-ok-btn')) { + if (_reservedCardId) return; // already holding — must NVM first + var card = e.target.closest('.sig-card'); + if (card) doReserve(card); + return; + } + if (e.target.closest('.sig-nvm-btn')) { + doRelease(); + return; + } var card = e.target.closest('.sig-card'); if (!card) return; - if (!isEligible()) return; - var activeRole = getActiveRole(); - var cardId = card.dataset.cardId; - var deckType = card.dataset.deck; - window.showGuard(card, 'Select this significator?', function () { - fetch(selectUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-CSRFToken': getCsrf(), - }, - body: 'card_id=' + encodeURIComponent(cardId) + '&deck_type=' + encodeURIComponent(deckType), - }).then(function (response) { - if (response.ok) { - applySelection(cardId, activeRole, deckType); - } - }); - }); + if (_reservedCardId) return; // locked until NVM + var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole; + var isOwnReserved = card.classList.contains('sig-reserved--own'); + if (reservedByOther || isOwnReserved) return; + focusCard(card); }); } - window.addEventListener('room:sig_selected', function (e) { - if (!sigDeck) return; - var cardId = String(e.detail.card_id); - var role = e.detail.role; - var deckType = e.detail.deck_type; - // Idempotent — skip if this copy already removed (local selector already did it) - if (!sigDeck.querySelector('.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]')) return; - applySelection(cardId, role, deckType); - }); - if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } + + // ── Test API ────────────────────────────────────────────────────────── + window.SigSelect = { + _testInit: function () { + _focusedCardEl = null; + _reservedCardId = null; + _stageFrozen = false; + _requestInFlight = false; + init(); + }, + _setFrozen: function (v) { _stageFrozen = v; }, + _setReservedCardId: function (id) { _reservedCardId = id; }, + }; }()); diff --git a/src/apps/epic/tests/integrated/test_consumers.py b/src/apps/epic/tests/integrated/test_consumers.py index 5490c1d..0133d8e 100644 --- a/src/apps/epic/tests/integrated/test_consumers.py +++ b/src/apps/epic/tests/integrated/test_consumers.py @@ -164,3 +164,98 @@ class CursorMoveConsumerTest(TransactionTestCase): await pc_comm.disconnect() await bc_comm.disconnect() + + +@tag('channels') +@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) +class SigHoverConsumerTest(TransactionTestCase): + """sig_hover messages sent by a client are forwarded within the polarity group only.""" + + async def _make_communicator(self, user, room): + client = Client() + await database_sync_to_async(client.force_login)(user) + session_key = await database_sync_to_async(lambda: client.session.session_key)() + comm = WebsocketCommunicator( + application, + f"/ws/room/{room.id}/", + headers=[(b"cookie", f"sessionid={session_key}".encode())], + ) + connected, _ = await comm.connect() + self.assertTrue(connected) + return comm + + async def test_sig_hover_forwarded_to_polarity_group(self): + pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io") + nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io") + room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user) + await database_sync_to_async(TableSeat.objects.create)( + room=room, gamer=pc_user, slot_number=1, role="PC" + ) + await database_sync_to_async(TableSeat.objects.create)( + room=room, gamer=nc_user, slot_number=2, role="NC" + ) + + pc_comm = await self._make_communicator(pc_user, room) + nc_comm = await self._make_communicator(nc_user, room) + + await pc_comm.send_json_to({ + "type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True + }) + + msg = await nc_comm.receive_json_from(timeout=2) + self.assertEqual(msg["type"], "sig_hover") + self.assertEqual(msg["card_id"], "abc-123") + self.assertEqual(msg["role"], "PC") + self.assertTrue(msg["active"]) + + await pc_comm.disconnect() + await nc_comm.disconnect() + + async def test_sig_hover_not_forwarded_to_other_polarity(self): + pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io") + bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io") + room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user) + await database_sync_to_async(TableSeat.objects.create)( + room=room, gamer=pc_user, slot_number=1, role="PC" + ) + await database_sync_to_async(TableSeat.objects.create)( + room=room, gamer=bc_user, slot_number=2, role="BC" + ) + + pc_comm = await self._make_communicator(pc_user, room) + bc_comm = await self._make_communicator(bc_user, room) + + await pc_comm.send_json_to({ + "type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True + }) + + self.assertTrue(await bc_comm.receive_nothing(timeout=1)) + + await pc_comm.disconnect() + await bc_comm.disconnect() + + async def test_sig_reserved_broadcast_received_by_polarity_group(self): + pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io") + nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io") + room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user) + await database_sync_to_async(TableSeat.objects.create)( + room=room, gamer=pc_user, slot_number=1, role="PC" + ) + await database_sync_to_async(TableSeat.objects.create)( + room=room, gamer=nc_user, slot_number=2, role="NC" + ) + + nc_comm = await self._make_communicator(nc_user, room) + + channel_layer = get_channel_layer() + await channel_layer.group_send( + f"cursors_{room.id}_levity", + {"type": "sig_reserved", "card_id": "card-xyz", "role": "PC", "reserved": True}, + ) + + msg = await nc_comm.receive_json_from(timeout=2) + self.assertEqual(msg["type"], "sig_reserved") + self.assertEqual(msg["card_id"], "card-xyz") + self.assertTrue(msg["reserved"]) + + await nc_comm.disconnect() diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 45cf059..64b44c4 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -4,10 +4,13 @@ from django.test import TestCase from django.urls import reverse from django.utils import timezone +from django.db import IntegrityError + from apps.lyric.models import Token, User from apps.epic.models import ( - DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard, - debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat, + DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, + debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards, + sig_seat_order, active_sig_seat, ) @@ -360,3 +363,119 @@ class SigCardFieldTest(TestCase): self.card.delete() self.seat.refresh_from_db() self.assertIsNone(self.seat.significator) + + +# ── SigReservation model ────────────────────────────────────────────────────── + +def _make_sig_card(deck_variant, suit, number): + name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"} + card, _ = TarotCard.objects.get_or_create( + deck_variant=deck_variant, + slug=f"{name_map[number].lower()}-of-{suit.lower()}-em", + defaults={ + "arcana": "MINOR", "suit": suit, "number": number, + "name": f"{name_map[number]} of {suit.capitalize()}", + }, + ) + return card + + +class SigReservationModelTest(TestCase): + def setUp(self): + self.earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + self.owner = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Sig Room", owner=self.owner) + self.card = _make_sig_card(self.earthman, "WANDS", 14) + self.seat = TableSeat.objects.create( + room=self.room, gamer=self.owner, slot_number=1, role="PC" + ) + + def test_can_create_sig_reservation(self): + res = SigReservation.objects.create( + room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity" + ) + self.assertEqual(res.role, "PC") + self.assertEqual(res.polarity, "levity") + self.assertIsNotNone(res.reserved_at) + + def test_one_reservation_per_gamer_per_room(self): + SigReservation.objects.create( + room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity" + ) + card2 = _make_sig_card(self.earthman, "CUPS", 13) + with self.assertRaises(IntegrityError): + SigReservation.objects.create( + room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity" + ) + + def test_same_card_blocked_within_same_polarity(self): + gamer2 = User.objects.create(email="nc@test.io") + TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC") + SigReservation.objects.create( + room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity" + ) + with self.assertRaises(IntegrityError): + SigReservation.objects.create( + room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity" + ) + + def test_same_card_allowed_across_polarity(self): + """A gravity gamer may reserve the same card instance as a levity gamer + — each polarity has its own independent pile.""" + gamer2 = User.objects.create(email="bc@test.io") + TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC") + SigReservation.objects.create( + room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity" + ) + res2 = SigReservation.objects.create( + room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity" + ) + self.assertIsNotNone(res2.pk) + + def test_deleting_reservation_clears_slot(self): + res = SigReservation.objects.create( + room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity" + ) + res.delete() + self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists()) + + +class SigCardHelperTest(TestCase): + """levity_sig_cards() and gravity_sig_cards() return 18 cards each. + Relies on the Earthman deck seeded by migrations (no manual card creation). + """ + + def setUp(self): + # Earthman deck is already seeded by migrations + self.earthman = DeckVariant.objects.get(slug="earthman") + self.owner = User.objects.create(email="founder@test.io") + self.owner.equipped_deck = self.earthman + self.owner.save() + self.room = Room.objects.create(name="Card Test", owner=self.owner) + + def test_levity_sig_cards_returns_18(self): + cards = levity_sig_cards(self.room) + self.assertEqual(len(cards), 18) + + def test_gravity_sig_cards_returns_18(self): + cards = gravity_sig_cards(self.room) + self.assertEqual(len(cards), 18) + + def test_levity_and_gravity_share_same_card_objects(self): + """Both piles draw from the same 18 TarotCard instances — visual distinction + comes from CSS polarity class, not separate card model records.""" + levity = levity_sig_cards(self.room) + gravity = gravity_sig_cards(self.room) + self.assertEqual( + sorted(c.pk for c in levity), + sorted(c.pk for c in gravity), + ) + + def test_returns_empty_when_no_equipped_deck(self): + self.owner.equipped_deck = None + self.owner.save() + self.assertEqual(levity_sig_cards(self.room), []) + self.assertEqual(gravity_sig_cards(self.room), []) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 824966e..e38d3ac 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import ANY, patch from django.test import TestCase from django.urls import reverse @@ -8,7 +8,7 @@ from django.utils import timezone from apps.drama.models import GameEvent from apps.lyric.models import Token, User from apps.epic.models import ( - DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard, + DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, ) @@ -943,9 +943,9 @@ class SigSelectRenderingTest(TestCase): response = self.client.get(self.url) self.assertContains(response, "id_sig_deck") - def test_sig_deck_contains_36_sig_cards(self): + def test_sig_deck_contains_18_sig_cards(self): response = self.client.get(self.url) - self.assertEqual(response.content.decode().count('sig-card'), 36) + self.assertEqual(response.content.decode().count('data-card-id='), 18) def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self): response = self.client.get(self.url) @@ -1119,3 +1119,154 @@ class SelectRoleRecordsRoleSelectedTest(TestCase): data={"role": "PC"}, ) self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0) + + +# ── sig_reserve view ────────────────────────────────────────────────────────── + +class SigReserveViewTest(TestCase): + """sig_reserve — provisional card hold; OK/NVM flow.""" + + def setUp(self): + self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self) + # founder (gamers[0]) is PC — levity polarity + self.client.force_login(self.gamers[0]) + self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id}) + + def _reserve(self, card_id=None, action="reserve", client=None): + c = client or self.client + return c.post(self.url, data={ + "card_id": card_id or self.card.id, + "action": action, + }) + + # ── happy-path reserve ──────────────────────────────────────────────── + + def test_reserve_creates_sig_reservation(self): + self._reserve() + self.assertTrue(SigReservation.objects.filter( + room=self.room, gamer=self.gamers[0], card=self.card + ).exists()) + + def test_reserve_returns_200(self): + response = self._reserve() + self.assertEqual(response.status_code, 200) + + def test_reservation_has_correct_polarity(self): + self._reserve() + res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0]) + self.assertEqual(res.polarity, "levity") + + def test_gravity_gamer_reservation_has_gravity_polarity(self): + # gamers[3] is SC (index 3 → role SC → but _full_sig_setUp uses SIG_SEAT_ORDER + # which assigns PC→NC→EC→SC→AC→BC, so slot 4 = SC, slot 5 = AC, slot 6 = BC) + # gamers[5] is BC → gravity + bc_client = self.client.__class__() + bc_client.force_login(self.gamers[5]) + self._reserve(client=bc_client) + res = SigReservation.objects.get(room=self.room, gamer=self.gamers[5]) + self.assertEqual(res.polarity, "gravity") + + # ── conflict handling ───────────────────────────────────────────────── + + def test_reserve_taken_card_same_polarity_returns_409(self): + # NC (gamers[1]) reserves the same card first — both are levity + nc_client = self.client.__class__() + nc_client.force_login(self.gamers[1]) + self._reserve(client=nc_client) + # Now PC tries to grab the same card — should be blocked + response = self._reserve() + self.assertEqual(response.status_code, 409) + + def test_reserve_taken_card_cross_polarity_succeeds(self): + # BC (gamers[5], gravity) reserves the same card — different polarity, allowed + bc_client = self.client.__class__() + bc_client.force_login(self.gamers[5]) + self._reserve(client=bc_client) + response = self._reserve() # PC (levity) grabs same card + self.assertEqual(response.status_code, 200) + + def test_reserve_different_card_while_holding_returns_409(self): + """Cannot OK a different card while holding one — must NVM first.""" + card_b = TarotCard.objects.filter( + deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=12 + ).first() + self._reserve() # PC grabs card A → 200 + response = self._reserve(card_id=card_b.id) # tries card B → 409 + self.assertEqual(response.status_code, 409) + # Original reservation still intact + reservations = SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]) + self.assertEqual(reservations.count(), 1) + self.assertEqual(reservations.first().card, self.card) + + def test_reserve_same_card_again_is_idempotent(self): + """Re-POSTing the same card while already holding it returns 200 (no-op).""" + self._reserve() + response = self._reserve() # same card again + self.assertEqual(response.status_code, 200) + self.assertEqual( + SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).count(), 1 + ) + + def test_reserve_blocked_then_unblocked_after_release(self): + """After NVM, a new card can be OK'd.""" + card_b = TarotCard.objects.filter( + deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=12 + ).first() + self._reserve() # hold card A + self._reserve(action="release") # NVM + response = self._reserve(card_id=card_b.id) # now card B → 200 + self.assertEqual(response.status_code, 200) + self.assertTrue(SigReservation.objects.filter( + room=self.room, gamer=self.gamers[0], card=card_b + ).exists()) + + # ── release ─────────────────────────────────────────────────────────── + + def test_release_deletes_reservation(self): + self._reserve() + self._reserve(action="release") + self.assertFalse(SigReservation.objects.filter( + room=self.room, gamer=self.gamers[0] + ).exists()) + + def test_release_returns_200(self): + self._reserve() + response = self._reserve(action="release") + self.assertEqual(response.status_code, 200) + + def test_release_with_no_reservation_still_200(self): + """NVM when nothing held is harmless.""" + response = self._reserve(action="release") + self.assertEqual(response.status_code, 200) + + # ── guards ──────────────────────────────────────────────────────────── + + def test_reserve_requires_login(self): + self.client.logout() + response = self._reserve() + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_reserve_requires_seated_gamer(self): + outsider = User.objects.create(email="outsider@test.io") + outsider_client = self.client.__class__() + outsider_client.force_login(outsider) + response = self._reserve(client=outsider_client) + self.assertEqual(response.status_code, 403) + + def test_reserve_wrong_phase_returns_400(self): + self.room.table_status = Room.ROLE_SELECT + self.room.save() + response = self._reserve() + self.assertEqual(response.status_code, 400) + + def test_reserve_broadcasts_ws(self): + with patch("apps.epic.views._notify_sig_reserved") as mock_notify: + self._reserve() + mock_notify.assert_called_once() + + def test_release_broadcasts_ws(self): + self._reserve() + with patch("apps.epic.views._notify_sig_reserved") as mock_notify: + self._reserve(action="release") + mock_notify.assert_called_once() diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index adb972e..0507cf6 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ path('room//pick-sigs', views.pick_sigs, name='pick_sigs'), path('room//select-role', views.select_role, name='select_role'), path('room//select-sig', views.select_sig, name='select_sig'), + path('room//sig-reserve', views.sig_reserve, name='sig_reserve'), path('room//gate/invite', views.invite_gamer, name='invite_gamer'), path('room//gate/status', views.gate_status, name='gate_status'), path('room//delete', views.delete_room, name='delete_room'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 775baaf..aa287ed 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,3 +1,4 @@ +import json from datetime import timedelta from asgiref.sync import async_to_sync @@ -10,8 +11,9 @@ from django.utils import timezone from apps.drama.models import GameEvent, record from apps.epic.models import ( - GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck, - active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order, + GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, TarotDeck, + active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, + select_token, sig_deck_cards, ) from apps.lyric.models import Token @@ -74,6 +76,20 @@ def _notify_sig_selected(room_id, card_id, role, deck_type='levity'): ) +_LEVITY_ROLES = {'PC', 'NC', 'SC'} +_GRAVITY_ROLES = {'BC', 'EC', 'AC'} + + +def _notify_sig_reserved(room_id, card_id, role, reserved): + """Broadcast a sig_reserved event to the matching polarity cursor group.""" + polarity = 'levity' if role in _LEVITY_ROLES else 'gravity' + async_to_sync(get_channel_layer().group_send)( + f'cursors_{room_id}_{polarity}', + {'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None, + 'role': role, 'reserved': reserved}, + ) + + SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} @@ -216,16 +232,35 @@ def _role_select_context(room, user): } if room.table_status == Room.SIG_SELECT: user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None - partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None - partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None + user_role = user_seat.role if user_seat else None + user_polarity = None + if user_role in _LEVITY_ROLES: + user_polarity = 'levity' + elif user_role in _GRAVITY_ROLES: + user_polarity = 'gravity' + ctx["user_seat"] = user_seat - ctx["partner_seat"] = partner_seat - ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number") - raw_sig_cards = sig_deck_cards(room) - half = len(raw_sig_cards) // 2 - ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]] - ctx["sig_seats"] = sig_seat_order(room) - ctx["sig_active_seat"] = active_sig_seat(room) + ctx["user_polarity"] = user_polarity + ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve" + + # 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: + polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY + reservations = { + str(res.card_id): res.role + for res in room.sig_reservations.filter(polarity=polarity_const) + } + else: + reservations = {} + ctx["sig_reservations_json"] = json.dumps(reservations) + + if user_polarity == 'levity': + ctx["sig_cards"] = levity_sig_cards(room) + elif user_polarity == 'gravity': + ctx["sig_cards"] = gravity_sig_cards(room) + else: + ctx["sig_cards"] = [] return ctx @@ -526,6 +561,60 @@ def gate_status(request, room_id): return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx) +@login_required +def sig_reserve(request, room_id): + """Provisional card hold (OK / NVM) during SIG_SELECT. + POST body: card_id=, action=reserve|release + """ + 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 = room.table_seats.filter(gamer=request.user).first() + if not user_seat or not user_seat.role: + return HttpResponse(status=403) + + action = request.POST.get("action", "reserve") + + if action == "release": + SigReservation.objects.filter(room=room, gamer=request.user).delete() + _notify_sig_reserved(room_id, None, user_seat.role, reserved=False) + return HttpResponse(status=200) + + # Reserve action + card_id = request.POST.get("card_id") + try: + card = TarotCard.objects.get(pk=card_id) + except TarotCard.DoesNotExist: + return HttpResponse(status=400) + + polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY + + # Block if another gamer in the same polarity already holds this card + if SigReservation.objects.filter( + room=room, card=card, polarity=polarity + ).exclude(gamer=request.user).exists(): + return HttpResponse(status=409) + + # Block if this gamer already holds a *different* card — must NVM first + existing = SigReservation.objects.filter(room=room, gamer=request.user).first() + if existing and existing.card != card: + return HttpResponse(status=409) + + # Idempotent: already holding the same card + if existing: + return HttpResponse(status=200) + + SigReservation.objects.create( + room=room, gamer=request.user, card=card, + role=user_seat.role, polarity=polarity, + ) + _notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True) + return HttpResponse(status=200) + + @login_required def select_sig(request, room_id): if request.method != "POST": diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js new file mode 100644 index 0000000..61f376f --- /dev/null +++ b/src/static/tests/SigSelectSpec.js @@ -0,0 +1,215 @@ +describe("SigSelect", () => { + let testDiv, stageCard, card; + + function makeFixture({ reservations = '{}' } = {}) { + testDiv = document.createElement("div"); + testDiv.innerHTML = ` +
+
+
+ +
+
+
+
+ K +
+
+ + +
+
+ + + +
+
+
+
+
+ `; + document.body.appendChild(testDiv); + stageCard = testDiv.querySelector(".sig-stage-card"); + card = testDiv.querySelector(".sig-card"); + window.fetch = jasmine.createSpy("fetch").and.returnValue( + Promise.resolve({ ok: true }) + ); + window._roomSocket = { readyState: -1, send: jasmine.createSpy("send") }; + SigSelect._testInit(); + } + + afterEach(() => { + if (testDiv) testDiv.remove(); + delete window._roomSocket; + }); + + // ── Stage reveal on mouseenter ─────────────────────────────────────── // + + describe("stage preview", () => { + beforeEach(() => makeFixture()); + + it("shows the stage card on mouseenter", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + }); + + it("hides the stage card on mouseleave when not frozen", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(stageCard.style.display).toBe("none"); + }); + + it("does NOT hide the stage card on mouseleave when frozen (reserved)", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + SigSelect._setFrozen(true); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + }); + }); + + // ── Card focus (click → OK overlay) ───────────────────────────────── // + + describe("card click", () => { + beforeEach(() => makeFixture()); + + it("adds .sig-focused to the clicked card", () => { + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + }); + + it("shows the stage card after click", () => { + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + }); + + it("does not focus a card reserved by another role", () => { + card.dataset.reservedBy = "NC"; + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(false); + }); + }); + + // ── Touch: OK btn tap allows synthetic click through ──────────────── // + + describe("touch on OK button", () => { + beforeEach(() => makeFixture()); + + it("touchstart on OK btn does not call preventDefault (allows synthetic click)", () => { + // First tap the card body to show OK + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + + // Now tap the OK button — touchstart should NOT preventDefault + var okBtn = card.querySelector(".sig-ok-btn"); + var touchEvent = new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 1, target: okBtn })], + }); + okBtn.dispatchEvent(touchEvent); + expect(touchEvent.defaultPrevented).toBe(false); + }); + + it("touchstart on card body (not OK btn) calls preventDefault", () => { + var touchEvent = new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 1, target: card })], + }); + card.dispatchEvent(touchEvent); + expect(touchEvent.defaultPrevented).toBe(true); + }); + }); + + // ── Touch outside grid dismisses stage (mobile) ───────────────────── // + + describe("touch outside grid", () => { + beforeEach(() => makeFixture()); + + it("dismisses stage preview when touching outside the grid (unfocused state)", () => { + // Focus a card first + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + + // Touch on the sig-stage (outside the grid) + var stage = testDiv.querySelector(".sig-stage"); + stage.dispatchEvent(new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 2, target: stage })], + })); + expect(stageCard.style.display).toBe("none"); + expect(card.classList.contains("sig-focused")).toBe(false); + }); + + it("does NOT dismiss stage preview when frozen (card reserved)", () => { + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + SigSelect._setFrozen(true); + // _focusedCardEl is set but frozen — use internal state trick via _setFrozen + // We also need a focused card; simulate it by setting frozen after focus + var stage = testDiv.querySelector(".sig-stage"); + stage.dispatchEvent(new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 3, target: stage })], + })); + expect(stageCard.style.display).toBe(""); + }); + }); + + // ── Lock after reservation ─────────────────────────────────────────── // + + describe("lock after reservation", () => { + beforeEach(() => makeFixture()); + + it("does not focus another card while one is reserved", () => { + // Simulate a reservation on some other card (not this one) + SigSelect._setReservedCardId("99"); + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(false); + }); + + it("does not call fetch when OK is clicked while a different card is reserved", () => { + SigSelect._setReservedCardId("99"); + var okBtn = card.querySelector(".sig-ok-btn"); + okBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(window.fetch).not.toHaveBeenCalled(); + }); + + it("does not call preventDefault on touchstart while a card is reserved", () => { + SigSelect._setReservedCardId("99"); + var touchEvent = new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 1, target: card })], + }); + card.dispatchEvent(touchEvent); + expect(touchEvent.defaultPrevented).toBe(false); + }); + + it("allows focus again after reservation is cleared", () => { + SigSelect._setReservedCardId("99"); + SigSelect._setReservedCardId(null); + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + }); + }); +}); diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index e6fe12b..1260321 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -21,10 +21,12 @@ + + diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index f45645a..6d9c8c9 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -211,7 +211,7 @@ body { border-bottom: none; border-right: 0.1rem solid rgba(var(--secUser), 0.4); background-color: rgba(var(--priUser), 1); - z-index: 300; + z-index: 100; overflow: hidden; .container-fluid { diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 6775ea2..23ad486 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -817,72 +817,245 @@ $card-h: 60px; } -// ─── Significator deck (SIG_SELECT phase) ────────────────────────────────── +// ─── Sig Select overlay (SIG_SELECT phase) ──────────────────────────────────── +// +// Two overlays (levity / gravity) run in parallel, one per polarity group. +// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal. +// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll). -// When the sig deck is present, switch room-page from centred to column layout -.room-page:has(#id_sig_deck) { - flex-direction: column; - align-items: stretch; - justify-content: flex-start; - gap: 1rem; - - .room-shell { - max-height: 50vh; - } +html:has(.sig-backdrop) { + overflow: hidden; } -#id_sig_deck { +.sig-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(5px); + z-index: 100; + pointer-events: none; +} + +.sig-overlay { + position: fixed; + inset: 0; display: flex; - flex-wrap: wrap; - gap: 0.4rem; + align-items: stretch; + justify-content: center; + z-index: 120; + pointer-events: none; +} + +.sig-modal { + pointer-events: auto; + display: flex; + flex-direction: column; + width: 100%; // respects overlay padding-right set by JS + max-width: 420px; + max-height: 100%; // respects overlay padding-bottom set by JS +} + +// ─── Stage ──────────────────────────────────────────────────────────────────── +// flex: 1 — fills all space above the card grid; no background (backdrop blur). +// Row layout: preview card bottom-left, stat block fills the right. +// Card width is set by sizeSigCard() in room.js (smaller of 40% stage width or +// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle +// container query units inside min(). + +.sig-stage { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + align-items: flex-end; padding: 0.75rem; - overflow-y: auto; - align-content: flex-start; - max-height: 45vh; - scrollbar-width: thin; - scrollbar-color: rgba(var(--terUser), 0.3) transparent; + gap: 0.75rem; + + // Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height. + .sig-stage-card { + flex-shrink: 0; + width: var(--sig-card-w, 120px); + height: auto; + aspect-ratio: 5 / 8; + border-radius: 0.5rem; + background: rgba(var(--priUser), 1); + border: 0.15rem solid rgba(var(--secUser), 0.6); + display: flex; + flex-direction: column; + position: relative; + padding: 0.25rem; + overflow: hidden; + + // game-kit sets .fan-card-corner { position: absolute; top/left offsets } + // so these just need display/font overrides; the corners land at the card edges. + .fan-card-corner--tl { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.1; + gap: 0.1rem; + + .fan-corner-rank { font-size: 1rem; font-weight: 700; } + i { font-size: 0.75rem; } + } + + .fan-card-corner--br { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.1; + gap: 0.1rem; + + .fan-corner-rank { font-size: 0.9rem; font-weight: 700; } + i { font-size: 0.75rem; } + } + + .fan-card-face { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.25rem 0.15rem; + gap: 0.2rem; + + .fan-card-name-group { font-size: 0.55rem; opacity: 0.6; } + .fan-card-name { font-size: 0.7rem; font-weight: 600; } + .fan-card-arcana { font-size: 0.5rem; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; } + .fan-card-correspondence{ font-size: 0.5rem; opacity: 0.5; } + } + } + + // Stat block — hidden until a card is previewed; fills remaining stage width. + .sig-stat-block { + flex: 1; + align-self: stretch; + background: rgba(var(--priUser), 0.25); + border-radius: 0.4rem; + border: 0.1rem solid rgba(var(--terUser), 0.15); + display: none; + } + + &.sig-stage--frozen .sig-stat-block { display: block; } +} + +// ─── Mini card grid ─────────────────────────────────────────────────────────── +// flex: 0 0 auto — shrinks to card content; no background (backdrop blur). +// align-content: start prevents CSS grid from distributing extra height between rows. + +.sig-deck-grid { + flex: 0 0 auto; + display: grid; + grid-template-columns: repeat(6, 1fr); + align-content: start; + gap: 2px; + padding: 4px; + overflow: hidden; + margin: 0 1rem 5rem 4rem; } .sig-card { - width: 70px; - height: 108px; - border-radius: 0.4rem; - background: rgba(var(--priUser), 1); - border: 0.1rem solid rgba(var(--secUser), 0.4); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - padding: 0.25rem; - cursor: pointer; - transition: transform 0.15s, border-color 0.15s; + aspect-ratio: 5 / 8; + border-radius: 3px; + background: rgba(var(--priUser), 0.97); + border: 1px solid rgba(var(--secUser), 0.3); position: relative; + cursor: grab; + transition: border-color 0.15s, box-shadow 0.15s; + overflow: hidden; - &:hover { - border-color: rgba(var(--secUser), 1); - transform: translateY(-2px); - box-shadow: 0 0 0.5rem rgba(var(--secUser), 0.3); - } - - // Bottom corner is redundant at this size - .fan-card-corner--br { display: none; } - - // Top corner — override game-kit's 1.5rem defaults with deeper nesting + // game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem } + // Override: center the element within the card instead. .fan-card-corner--tl { - .fan-corner-rank { font-size: 0.65rem; padding: 0; } - i { font-size: 0.55rem; } + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + gap: 0; // game-kit has gap:0.15rem — too large at 0.5rem font-size + + .fan-corner-rank { font-size: 1rem; font-weight: 700; } + i { font-size: 0.75rem; } } - // Face — deeper nesting to beat game-kit specificity - .fan-card-face { - padding: 0.25rem 0.2rem; - gap: 0.1rem; + // OK / NVM overlay — appears on click (focused) or own reservation + .sig-card-actions { + position: absolute; + inset: 0; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + background: rgba(var(--priUser), 0.92); + border-radius: inherit; - .fan-card-name-group { font-size: 0.38rem; } - .fan-card-name { font-size: 0.5rem; } - .fan-card-arcana { font-size: 0.35rem; } + .sig-nvm-btn { display: none; } + } + + &.sig-focused .sig-card-actions { display: flex; } + &.sig-reserved--own .sig-card-actions { + display: flex; + .sig-ok-btn { display: none; } + .sig-nvm-btn { display: flex; } + } + + // Cursor anchors strip — bottom of card + .sig-card-cursors { + position: absolute; + bottom: 2px; + left: 2px; + right: 2px; + display: flex; + justify-content: space-between; + } + + &:hover:not([data-reserved-by]) { + border-color: rgba(var(--secUser), 0.8); + box-shadow: 0 0 4px rgba(var(--secUser), 0.25); + } + + &.sig-reserved { + border-color: rgba(var(--terUser), 1); + box-shadow: + 0 0 0.4rem rgba(var(--terUser), 0.7), + 0 0 1rem rgba(var(--ninUser), 0.4); + cursor: not-allowed; + } + + &.sig-reserved--own { + border-color: rgba(var(--secUser), 1); + box-shadow: + 0 0 0.4rem rgba(var(--secUser), 0.7), + 0 0 1rem rgba(var(--ninUser), 0.5); + cursor: grabbing; } } +// ─── Cursor anchors ─────────────────────────────────────────────────────────── +// +// Three tiny dots along the bottom of each mini card, one per role in the group. +// Inactive: invisible. Active (another gamer is hovering): coloured dot. + +.sig-cursor { + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background: transparent; + transition: background 0.1s; + + &.active { + background: rgba(var(--terUser), 1); + box-shadow: 0 0 3px rgba(var(--ninUser), 0.8); + } +} + +// ─── Sig select: landscape overrides ───────────────────────────────────────── +// Wider viewport → 2 rows of 9 cards; modal allowed to fill available width. + +@media (orientation: landscape) { + .sig-modal { max-width: none; } + .sig-deck-grid { grid-template-columns: repeat(9, 1fr); } +} + // ─── Seat tray — see _tray.scss ───────────────────────────────────────────── diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js new file mode 100644 index 0000000..61f376f --- /dev/null +++ b/src/static_src/tests/SigSelectSpec.js @@ -0,0 +1,215 @@ +describe("SigSelect", () => { + let testDiv, stageCard, card; + + function makeFixture({ reservations = '{}' } = {}) { + testDiv = document.createElement("div"); + testDiv.innerHTML = ` +
+
+
+ +
+
+
+
+ K +
+
+ + +
+
+ + + +
+
+
+
+
+ `; + document.body.appendChild(testDiv); + stageCard = testDiv.querySelector(".sig-stage-card"); + card = testDiv.querySelector(".sig-card"); + window.fetch = jasmine.createSpy("fetch").and.returnValue( + Promise.resolve({ ok: true }) + ); + window._roomSocket = { readyState: -1, send: jasmine.createSpy("send") }; + SigSelect._testInit(); + } + + afterEach(() => { + if (testDiv) testDiv.remove(); + delete window._roomSocket; + }); + + // ── Stage reveal on mouseenter ─────────────────────────────────────── // + + describe("stage preview", () => { + beforeEach(() => makeFixture()); + + it("shows the stage card on mouseenter", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + }); + + it("hides the stage card on mouseleave when not frozen", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(stageCard.style.display).toBe("none"); + }); + + it("does NOT hide the stage card on mouseleave when frozen (reserved)", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + SigSelect._setFrozen(true); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + }); + }); + + // ── Card focus (click → OK overlay) ───────────────────────────────── // + + describe("card click", () => { + beforeEach(() => makeFixture()); + + it("adds .sig-focused to the clicked card", () => { + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + }); + + it("shows the stage card after click", () => { + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + }); + + it("does not focus a card reserved by another role", () => { + card.dataset.reservedBy = "NC"; + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(false); + }); + }); + + // ── Touch: OK btn tap allows synthetic click through ──────────────── // + + describe("touch on OK button", () => { + beforeEach(() => makeFixture()); + + it("touchstart on OK btn does not call preventDefault (allows synthetic click)", () => { + // First tap the card body to show OK + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + + // Now tap the OK button — touchstart should NOT preventDefault + var okBtn = card.querySelector(".sig-ok-btn"); + var touchEvent = new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 1, target: okBtn })], + }); + okBtn.dispatchEvent(touchEvent); + expect(touchEvent.defaultPrevented).toBe(false); + }); + + it("touchstart on card body (not OK btn) calls preventDefault", () => { + var touchEvent = new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 1, target: card })], + }); + card.dispatchEvent(touchEvent); + expect(touchEvent.defaultPrevented).toBe(true); + }); + }); + + // ── Touch outside grid dismisses stage (mobile) ───────────────────── // + + describe("touch outside grid", () => { + beforeEach(() => makeFixture()); + + it("dismisses stage preview when touching outside the grid (unfocused state)", () => { + // Focus a card first + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.style.display).toBe(""); + + // Touch on the sig-stage (outside the grid) + var stage = testDiv.querySelector(".sig-stage"); + stage.dispatchEvent(new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 2, target: stage })], + })); + expect(stageCard.style.display).toBe("none"); + expect(card.classList.contains("sig-focused")).toBe(false); + }); + + it("does NOT dismiss stage preview when frozen (card reserved)", () => { + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + SigSelect._setFrozen(true); + // _focusedCardEl is set but frozen — use internal state trick via _setFrozen + // We also need a focused card; simulate it by setting frozen after focus + var stage = testDiv.querySelector(".sig-stage"); + stage.dispatchEvent(new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 3, target: stage })], + })); + expect(stageCard.style.display).toBe(""); + }); + }); + + // ── Lock after reservation ─────────────────────────────────────────── // + + describe("lock after reservation", () => { + beforeEach(() => makeFixture()); + + it("does not focus another card while one is reserved", () => { + // Simulate a reservation on some other card (not this one) + SigSelect._setReservedCardId("99"); + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(false); + }); + + it("does not call fetch when OK is clicked while a different card is reserved", () => { + SigSelect._setReservedCardId("99"); + var okBtn = card.querySelector(".sig-ok-btn"); + okBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(window.fetch).not.toHaveBeenCalled(); + }); + + it("does not call preventDefault on touchstart while a card is reserved", () => { + SigSelect._setReservedCardId("99"); + var touchEvent = new TouchEvent("touchstart", { + bubbles: true, + cancelable: true, + touches: [new Touch({ identifier: 1, target: card })], + }); + card.dispatchEvent(touchEvent); + expect(touchEvent.defaultPrevented).toBe(false); + }); + + it("allows focus again after reservation is cleared", () => { + SigSelect._setReservedCardId("99"); + SigSelect._setReservedCardId(null); + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + }); + }); +}); diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index e6fe12b..1260321 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -21,10 +21,12 @@ + + diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html new file mode 100644 index 0000000..84a273e --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html @@ -0,0 +1,64 @@ +{% load i18n %}{% comment %} +Sig Select overlay — dark Gaussian modal over the dormant table hex. +Rendered for the current user's polarity group only. +Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_json +{% endcomment %} +
+
+ +
+ +
+ + +
+
+ +
+ {% for card in sig_cards %} +
+
+ {{ card.corner_rank }} + {% if card.suit_icon %}{% endif %} +
+
+ + +
+
+ + + +
+
+ {% endfor %} +
+ +
+
diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index f8e4794..3a08f54 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -36,17 +36,7 @@ - {% if room.table_status == "SIG_SELECT" and sig_seats %} - {% for seat in sig_seats %} -
-
{{ seat.slot_number }}
-
- - {% if seat.gamer %}@{{ seat.gamer.username|default:seat.gamer.email }}{% endif %} - -
- {% endfor %} - {% else %} + {# Seats — fa-chair layout persists from ROLE_SELECT through SIG_SELECT #} {% for pos in gate_positions %}
@@ -59,33 +49,13 @@ {% endif %}
{% endfor %} - {% endif %} - {% if room.table_status == "SIG_SELECT" and sig_cards %} -
- {% for card, deck_type in sig_cards %} -
-
- {{ card.corner_rank }} - {% if card.suit_icon %}{% endif %} -
-
- {% if card.name_group %}

{{ card.name_group }}

{% endif %} -

{{ card.name_title }}

-

{{ card.get_arcana_display }}

-
-
- {{ card.corner_rank }} - {% if card.suit_icon %}{% endif %} -
-
- {% endfor %} -
+ {# Sig Select overlay — only shown to seated gamers in this polarity #} + {% if room.table_status == "SIG_SELECT" and user_polarity %} + {% include "apps/gameboard/_partials/_sig_select_overlay.html" %} {% endif %} {% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %} @@ -115,4 +85,4 @@ -{% endblock scripts %} \ No newline at end of file +{% endblock scripts %}