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) await self.channel_layer.group_discard(self.cursor_group, self.channel_name)
async def receive_json(self, content): 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( await self.channel_layer.group_send(
self.cursor_group, self.cursor_group,
{"type": "cursor_move", "x": content.get("x"), "y": content.get("y")}, {"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 @database_sync_to_async
def _get_seat(self, user): def _get_seat(self, user):
@@ -61,5 +72,11 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
async def sig_selected(self, event): async def sig_selected(self, event):
await self.send_json(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): async def cursor_move(self, event):
await self.send_json(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 datetime import timedelta
from django.db import models from django.db import models
from django.db.models import UniqueConstraint
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings from django.conf import settings
@@ -324,6 +325,37 @@ class TarotDeck(models.Model):
self.save(update_fields=["drawn_card_ids"]) 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 ───────────────────────────────────────────────── # ── Significator deck helpers ─────────────────────────────────────────────────
def sig_deck_cards(room): def sig_deck_cards(room):
@@ -358,6 +390,41 @@ def sig_deck_cards(room):
return unique_cards + unique_cards # × 2 = 36 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): def sig_seat_order(room):
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order.""" """Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)} _order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}

View File

@@ -20,6 +20,62 @@
window.addEventListener('resize', scaleTable); 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 () { (function () {
const roomPage = document.querySelector('.room-page'); const roomPage = document.querySelector('.room-page');
if (!roomPage) return; if (!roomPage) return;
@@ -27,6 +83,7 @@
const roomId = roomPage.dataset.roomId; const roomId = roomPage.dataset.roomId;
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`); 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) { ws.onmessage = function (event) {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);

View File

@@ -1,96 +1,272 @@
var SigSelect = (function () { 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() { var _focusedCardEl = null; // card currently shown in stage
for (var i = 0; i < SIG_ORDER.length; i++) { var _reservedCardId = null; // card with active reservation
var seat = document.querySelector('.table-seat[data-role="' + SIG_ORDER[i] + '"]'); var _stageFrozen = false; // true after OK — stage locks on reserved card
if (seat && !seat.dataset.sigDone) return SIG_ORDER[i]; var _requestInFlight = false;
}
return null;
}
function isEligible() {
return !!(userRole && userRole === getActiveRole());
}
function getCsrf() { function getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/); var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : ''; return m ? m[1] : '';
} }
function applySelection(cardId, role, deckType) { // ── Stage ──────────────────────────────────────────────────────────────
// 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(); });
// Mark this seat done, remove active function updateStage(cardEl) {
var seat = document.querySelector('.table-seat[data-role="' + role + '"]'); if (_stageFrozen) return;
if (seat) { if (!cardEl) {
seat.classList.remove('active'); stageCard.style.display = 'none';
seat.dataset.sigDone = '1'; stage.classList.remove('sig-stage--active');
_focusedCardEl = null;
return;
} }
_focusedCardEl = cardEl;
// Advance active to next seat var rank = cardEl.dataset.cornerRank || '';
var nextRole = getActiveRole(); var icon = cardEl.dataset.suitIcon || '';
if (nextRole) { var group = cardEl.dataset.nameGroup || '';
var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]'); var title = cardEl.dataset.nameTitle || '';
if (nextSeat) nextSeat.classList.add('active'); var arcana= cardEl.dataset.arcana || '';
} var corr = cardEl.dataset.correspondence || '';
// Place a card placeholder in inventory stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
var invSlot = document.getElementById('id_inv_sig_card'); stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
if (invSlot) { if (icon) {
var card = document.createElement('div'); el.className = 'fa-solid ' + icon + ' stage-suit-icon';
card.className = 'card'; el.style.display = '';
invSlot.appendChild(card); } 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() { function init() {
sigDeck = document.getElementById('id_sig_deck'); overlay = document.querySelector('.sig-overlay');
if (!sigDeck) return; if (!overlay) return;
selectUrl = sigDeck.dataset.selectSigUrl;
userRole = sigDeck.dataset.userRole;
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'); var card = e.target.closest('.sig-card');
if (!card) return; if (!card) return;
if (!isEligible()) return; if (_reservedCardId) return; // locked until NVM
var activeRole = getActiveRole(); var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
var cardId = card.dataset.cardId; var isOwnReserved = card.classList.contains('sig-reserved--own');
var deckType = card.dataset.deck; if (reservedByOther || isOwnReserved) return;
window.showGuard(card, 'Select this significator?', function () { focusCard(card);
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);
}
});
});
}); });
} }
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') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
} else { } else {
init(); 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 pc_comm.disconnect()
await bc_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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.db import IntegrityError
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import ( from apps.epic.models import (
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat, 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.card.delete()
self.seat.refresh_from_db() self.seat.refresh_from_db()
self.assertIsNone(self.seat.significator) 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 datetime import timedelta
from unittest.mock import patch from unittest.mock import ANY, patch
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
@@ -8,7 +8,7 @@ from django.utils import timezone
from apps.drama.models import GameEvent from apps.drama.models import GameEvent
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import ( 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) response = self.client.get(self.url)
self.assertContains(response, "id_sig_deck") 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) 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): def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
response = self.client.get(self.url) response = self.client.get(self.url)
@@ -1119,3 +1119,154 @@ class SelectRoleRecordsRoleSelectedTest(TestCase):
data={"role": "PC"}, data={"role": "PC"},
) )
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0) 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>/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-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>/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/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>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'), 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 datetime import timedelta
from asgiref.sync import async_to_sync 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.drama.models import GameEvent, record
from apps.epic.models import ( from apps.epic.models import (
GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, TarotDeck,
active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
select_token, sig_deck_cards,
) )
from apps.lyric.models import Token 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"} 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: if room.table_status == Room.SIG_SELECT:
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None 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 user_role = user_seat.role if user_seat else None
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role 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["user_seat"] = user_seat
ctx["partner_seat"] = partner_seat ctx["user_polarity"] = user_polarity
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number") ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
raw_sig_cards = sig_deck_cards(room)
half = len(raw_sig_cards) // 2 # Pre-load existing reservations for this polarity so JS can restore
ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]] # grabbed state on page load/refresh. Keyed by str(card_id) → role.
ctx["sig_seats"] = sig_seat_order(room) if user_polarity:
ctx["sig_active_seat"] = active_sig_seat(room) 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 return ctx
@@ -526,6 +561,60 @@ def gate_status(request, room_id):
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx) 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 @login_required
def select_sig(request, room_id): def select_sig(request, room_id):
if request.method != "POST": 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="Spec.js"></script>
<script src="RoleSelectSpec.js"></script> <script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script> <script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script> <script src="lib/jasmine-6.0.1/boot1.js"></script>

View File

@@ -211,7 +211,7 @@ body {
border-bottom: none; border-bottom: none;
border-right: 0.1rem solid rgba(var(--secUser), 0.4); border-right: 0.1rem solid rgba(var(--secUser), 0.4);
background-color: rgba(var(--priUser), 1); background-color: rgba(var(--priUser), 1);
z-index: 300; z-index: 100;
overflow: hidden; overflow: hidden;
.container-fluid { .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 html:has(.sig-backdrop) {
.room-page:has(#id_sig_deck) { overflow: hidden;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 1rem;
.room-shell {
max-height: 50vh;
}
} }
#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; display: flex;
flex-wrap: wrap; align-items: stretch;
gap: 0.4rem; 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; padding: 0.75rem;
overflow-y: auto; gap: 0.75rem;
align-content: flex-start;
max-height: 45vh; // Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height.
scrollbar-width: thin; .sig-stage-card {
scrollbar-color: rgba(var(--terUser), 0.3) transparent; 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 { .sig-card {
width: 70px; aspect-ratio: 5 / 8;
height: 108px; border-radius: 3px;
border-radius: 0.4rem; background: rgba(var(--priUser), 0.97);
background: rgba(var(--priUser), 1); border: 1px solid rgba(var(--secUser), 0.3);
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;
position: relative; position: relative;
cursor: grab;
transition: border-color 0.15s, box-shadow 0.15s;
overflow: hidden;
&:hover { // game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem }
border-color: rgba(var(--secUser), 1); // Override: center the element within the card instead.
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
.fan-card-corner--tl { .fan-card-corner--tl {
.fan-corner-rank { font-size: 0.65rem; padding: 0; } top: 50%;
i { font-size: 0.55rem; } 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 // OK / NVM overlay — appears on click (focused) or own reservation
.fan-card-face { .sig-card-actions {
padding: 0.25rem 0.2rem; position: absolute;
gap: 0.1rem; 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; } .sig-nvm-btn { display: none; }
.fan-card-name { font-size: 0.5rem; } }
.fan-card-arcana { font-size: 0.35rem; }
&.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 ───────────────────────────────────────────── // ─── 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="Spec.js"></script>
<script src="RoleSelectSpec.js"></script> <script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script> <script src="TraySpec.js"></script>
<script src="SigSelectSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script> <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> </div>
</div> </div>
{% if room.table_status == "SIG_SELECT" and sig_seats %} {# Seats — fa-chair layout persists from ROLE_SELECT through SIG_SELECT #}
{% 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 %}
{% for pos in gate_positions %} {% for pos in gate_positions %}
<div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}" <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 }}"> data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
@@ -59,33 +49,13 @@
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
{% if room.table_status == "SIG_SELECT" and sig_cards %} {# Sig Select overlay — only shown to seated gamers in this polarity #}
<div id="id_sig_deck" {% if room.table_status == "SIG_SELECT" and user_polarity %}
data-select-sig-url="{% url 'epic:select_sig' room.id %}" {% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
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>
{% endif %} {% endif %}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %} {% 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/role-select.js' %}"></script>
<script src="{% static 'apps/epic/sig-select.js' %}"></script> <script src="{% static 'apps/epic/sig-select.js' %}"></script>
<script src="{% static 'apps/epic/tray.js' %}"></script> <script src="{% static 'apps/epic/tray.js' %}"></script>
{% endblock scripts %} {% endblock scripts %}