sig-select sprint: SigReservation model + sig_reserve view (OK/NVM hold); full sig-select.js rewrite with stage preview, WS hover cursors, reservation lock (must NVM before OK-ing another card — enforced server-side 409 + JS guard); sizeSigModal() + sizeSigCard() in room.js (JS-based card sizing avoids libsass cqw/cqh limitation); stat block hidden until OK pressed; mobile touch: dismiss stage on outside-grid tap when unfocused; 17 IT + Jasmine specs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-05 22:01:23 -04:00
parent a15d91dfe6
commit c7370bda03
18 changed files with 1616 additions and 172 deletions

View File

@@ -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)

View File

@@ -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')],
},
),
]

View File

@@ -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)}

View File

@@ -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);

View File

@@ -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; },
};
}());

View File

@@ -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()

View File

@@ -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), [])

View File

@@ -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()

View File

@@ -16,6 +16,7 @@ urlpatterns = [
path('room/<uuid:room_id>/pick-sigs', views.pick_sigs, name='pick_sigs'),
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
path('room/<uuid:room_id>/select-sig', views.select_sig, name='select_sig'),
path('room/<uuid:room_id>/sig-reserve', views.sig_reserve, name='sig_reserve'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),

View File

@@ -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=<uuid>, 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":

View File

@@ -0,0 +1,215 @@
describe("SigSelect", () => {
let testDiv, stageCard, card;
function makeFixture({ reservations = '{}' } = {}) {
testDiv = document.createElement("div");
testDiv.innerHTML = `
<div class="sig-overlay"
data-polarity="levity"
data-user-role="PC"
data-reserve-url="/epic/room/test/sig-reserve"
data-reservations="${reservations.replace(/"/g, '&quot;')}">
<div class="sig-modal">
<div class="sig-stage">
<div class="sig-stage-card" style="display:none">
<span class="fan-corner-rank"></span>
<i class="stage-suit-icon"></i>
<p class="fan-card-name-group"></p>
<h3 class="fan-card-name"></h3>
<p class="fan-card-arcana"></p>
<p class="fan-card-correspondence"></p>
</div>
</div>
<div class="sig-deck-grid">
<div class="sig-card"
data-card-id="42"
data-corner-rank="K"
data-suit-icon=""
data-name-group="Pentacles"
data-name-title="King of Pentacles"
data-arcana="Minor Arcana"
data-correspondence="">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">K</span>
</div>
<div class="sig-card-actions">
<button class="sig-ok-btn btn btn-confirm">OK</button>
<button class="sig-nvm-btn btn btn-cancel">NVM</button>
</div>
<div class="sig-card-cursors">
<span class="sig-cursor sig-cursor--left"></span>
<span class="sig-cursor sig-cursor--mid"></span>
<span class="sig-cursor sig-cursor--right"></span>
</div>
</div>
</div>
</div>
</div>
`;
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);
});
});
});

View File

@@ -21,10 +21,12 @@
<script src="Spec.js"></script>
<script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script>
<!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>
<!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script>

View File

@@ -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 {

View File

@@ -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 ─────────────────────────────────────────────

View File

@@ -0,0 +1,215 @@
describe("SigSelect", () => {
let testDiv, stageCard, card;
function makeFixture({ reservations = '{}' } = {}) {
testDiv = document.createElement("div");
testDiv.innerHTML = `
<div class="sig-overlay"
data-polarity="levity"
data-user-role="PC"
data-reserve-url="/epic/room/test/sig-reserve"
data-reservations="${reservations.replace(/"/g, '&quot;')}">
<div class="sig-modal">
<div class="sig-stage">
<div class="sig-stage-card" style="display:none">
<span class="fan-corner-rank"></span>
<i class="stage-suit-icon"></i>
<p class="fan-card-name-group"></p>
<h3 class="fan-card-name"></h3>
<p class="fan-card-arcana"></p>
<p class="fan-card-correspondence"></p>
</div>
</div>
<div class="sig-deck-grid">
<div class="sig-card"
data-card-id="42"
data-corner-rank="K"
data-suit-icon=""
data-name-group="Pentacles"
data-name-title="King of Pentacles"
data-arcana="Minor Arcana"
data-correspondence="">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">K</span>
</div>
<div class="sig-card-actions">
<button class="sig-ok-btn btn btn-confirm">OK</button>
<button class="sig-nvm-btn btn btn-cancel">NVM</button>
</div>
<div class="sig-card-cursors">
<span class="sig-cursor sig-cursor--left"></span>
<span class="sig-cursor sig-cursor--mid"></span>
<span class="sig-cursor sig-cursor--right"></span>
</div>
</div>
</div>
</div>
</div>
`;
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);
});
});
});

View File

@@ -21,10 +21,12 @@
<script src="Spec.js"></script>
<script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script>
<!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>
<!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script>

View File

@@ -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 %}
<div class="sig-backdrop"></div>
<div class="sig-overlay"
data-polarity="{{ user_polarity }}"
data-user-role="{{ user_seat.role }}"
data-reserve-url="{{ sig_reserve_url }}"
data-reservations="{{ sig_reservations_json }}">
<div class="sig-modal">
<div class="sig-stage" id="id_sig_stage">
<div class="sig-stage-card" style="display:none">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
<div class="fan-card-face">
<p class="fan-card-name-group"></p>
<h3 class="fan-card-name"></h3>
<p class="fan-card-arcana"></p>
<p class="fan-card-correspondence"></p>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
</div>
</div>
<div class="sig-stat-block"></div>
</div>
<div class="sig-deck-grid" id="id_sig_deck">
{% for card in sig_cards %}
<div class="sig-card {{ user_polarity }}-deck"
data-card-id="{{ card.id }}"
data-suit-icon="{{ card.suit_icon }}"
data-corner-rank="{{ card.corner_rank }}"
data-name-group="{{ card.name_group }}"
data-name-title="{{ card.name_title }}"
data-arcana="{{ card.get_arcana_display }}"
data-correspondence="{{ card.correspondence|default:'' }}">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
<div class="sig-card-actions">
<button class="sig-ok-btn btn btn-confirm" type="button">OK</button>
<button class="sig-nvm-btn btn btn-cancel" type="button">NVM</button>
</div>
<div class="sig-card-cursors">
<span class="sig-cursor sig-cursor--left"></span>
<span class="sig-cursor sig-cursor--mid"></span>
<span class="sig-cursor sig-cursor--right"></span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>

View File

@@ -36,17 +36,7 @@
</div>
</div>
</div>
{% if room.table_status == "SIG_SELECT" and sig_seats %}
{% for seat in sig_seats %}
<div class="table-seat{% if seat == sig_active_seat %} active{% endif %}" data-role="{{ seat.role }}" data-slot="{{ seat.slot_number }}">
<div class="seat-portrait">{{ seat.slot_number }}</div>
<div class="seat-card-arc"></div>
<span class="seat-label">
{% if seat.gamer %}@{{ seat.gamer.username|default:seat.gamer.email }}{% endif %}
</span>
</div>
{% endfor %}
{% else %}
{# Seats — fa-chair layout persists from ROLE_SELECT through SIG_SELECT #}
{% for pos in gate_positions %}
<div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}"
data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
@@ -59,33 +49,13 @@
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% if room.table_status == "SIG_SELECT" and sig_cards %}
<div id="id_sig_deck"
data-select-sig-url="{% url 'epic:select_sig' room.id %}"
data-user-role="{{ user_seat.role|default:'' }}">
{% for card, deck_type in sig_cards %}
<div class="sig-card {{ deck_type }}-deck" data-card-id="{{ card.id }}" data-deck="{{ deck_type }}">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
<div class="fan-card-face">
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
<h3 class="fan-card-name">{{ card.name_title }}</h3>
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
</div>
{% endfor %}
</div>
{# 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 @@
<script src="{% static 'apps/epic/role-select.js' %}"></script>
<script src="{% static 'apps/epic/sig-select.js' %}"></script>
<script src="{% static 'apps/epic/tray.js' %}"></script>
{% endblock scripts %}
{% endblock scripts %}