Compare commits

..

4 Commits

Author SHA1 Message Date
Disco DeDisco
2892b51101 fix SigSelect Jasmine: return test API from IIFE; pend touch specs on desktop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
window.SigSelect was being clobbered by the IIFE's undefined return value
(var SigSelect = (function(){...window.SigSelect={...}}()) overwrites window.SigSelect
with undefined). Fixed by using return {} like RoleSelect does.

TouchEvent is not defined in desktop Firefox, so the 5 touch-related specs now
call pending() when the API is absent rather than throwing a ReferenceError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:14:56 -04:00
Disco DeDisco
871e94b298 sig-select landscape: stage card now visible; gear/kit btns in right sidebar column
sizeSigModal() no longer uses tray bottomInset in landscape (was over-shrinking the
modal, pushing the stage off-screen); fixed 60px kit-bag-handle clearance instead.
Gear btn + kit btn shifted into the 4rem right sidebar strip (right: 0.5rem) and
nudged down a quarter-rem so they clear the last card in the 9×2 grid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:02:32 -04:00
Disco DeDisco
c3ab78cc57 many deck changes, including pentacles to crowns, middle arcana, and major arcana fa icons 2026-04-05 22:32:40 -04:00
Disco DeDisco
c7370bda03 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>
2026-04-05 22:01:23 -04:00
24 changed files with 1895 additions and 196 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

@@ -0,0 +1,28 @@
# Generated by Django 6.0 on 2026-04-06 02:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0022_sig_reservation'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='icon',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AlterField(
model_name='tarotcard',
name='arcana',
field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6),
),
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,46 @@
"""
Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS.
Updates for every Earthman card where suit="PENTACLES":
- suit: "PENTACLES""CROWNS"
- name: " of Pentacles"" of Crowns"
- slug: "pentacles""crowns"
"""
from django.db import migrations
def pentacles_to_crowns(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"):
card.suit = "CROWNS"
card.name = card.name.replace(" of Pentacles", " of Crowns")
card.slug = card.slug.replace("pentacles", "crowns")
card.save(update_fields=["suit", "name", "slug"])
def crowns_to_pentacles(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"):
card.suit = "PENTACLES"
card.name = card.name.replace(" of Crowns", " of Pentacles")
card.slug = card.slug.replace("crowns", "pentacles")
card.save(update_fields=["suit", "name", "slug"])
class Migration(migrations.Migration):
dependencies = [
("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"),
]
operations = [
migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles),
]

View File

@@ -0,0 +1,62 @@
"""
Data migration: Earthman deck — court cards and major arcana icons.
1. Court cards (numbers 1114, all suits): arcana "MINOR""MIDDLE"
2. Major arcana icons (stored in TarotCard.icon):
0 (Nomad) → fa-hat-cowboy-side
1 (Schizo) → fa-hat-wizard
251 (rest) → fa-hand-dots
"""
from django.db import migrations
MAJOR_ICONS = {
0: "fa-hat-cowboy-side",
1: "fa-hat-wizard",
}
DEFAULT_MAJOR_ICON = "fa-hand-dots"
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Court cards → MIDDLE
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14]
).update(arcana="MIDDLE")
# Major arcana icons
for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"):
card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON)
card.save(update_fields=["icon"])
def backward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14]
).update(arcana="MINOR")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR"
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0024_earthman_pentacles_to_crowns"),
]
operations = [
migrations.RunPython(forward, reverse_code=backward),
]

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
@@ -204,20 +205,24 @@ class DeckVariant(models.Model):
class TarotCard(models.Model): class TarotCard(models.Model):
MAJOR = "MAJOR" MAJOR = "MAJOR"
MINOR = "MINOR" MINOR = "MINOR"
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
ARCANA_CHOICES = [ ARCANA_CHOICES = [
(MAJOR, "Major Arcana"), (MAJOR, "Major Arcana"),
(MINOR, "Minor Arcana"), (MINOR, "Minor Arcana"),
(MIDDLE, "Middle Arcana"),
] ]
WANDS = "WANDS" WANDS = "WANDS"
CUPS = "CUPS" CUPS = "CUPS"
SWORDS = "SWORDS" SWORDS = "SWORDS"
PENTACLES = "PENTACLES" # Fiorentine 4th suit PENTACLES = "PENTACLES" # Fiorentine 4th suit
CROWNS = "CROWNS" # Earthman 4th suit (renamed from Pentacles)
SUIT_CHOICES = [ SUIT_CHOICES = [
(WANDS, "Wands"), (WANDS, "Wands"),
(CUPS, "Cups"), (CUPS, "Cups"),
(SWORDS, "Swords"), (SWORDS, "Swords"),
(PENTACLES, "Pentacles"), (PENTACLES, "Pentacles"),
(CROWNS, "Crowns"),
] ]
deck_variant = models.ForeignKey( deck_variant = models.ForeignKey(
@@ -225,8 +230,9 @@ class TarotCard(models.Model):
on_delete=models.CASCADE, related_name="cards", on_delete=models.CASCADE, related_name="cards",
) )
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
arcana = models.CharField(max_length=5, choices=ARCANA_CHOICES) arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES)
suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True) suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
icon = models.CharField(max_length=50, blank=True, default='') # FA icon override (e.g. major arcana)
number = models.IntegerField() # 021 major (Fiorentine); 051 major (Earthman); 114 minor number = models.IntegerField() # 021 major (Fiorentine); 051 major (Earthman); 114 minor
slug = models.SlugField(max_length=120) slug = models.SlugField(max_length=120)
correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent
@@ -274,6 +280,8 @@ class TarotCard(models.Model):
@property @property
def suit_icon(self): def suit_icon(self):
if self.icon:
return self.icon
if self.arcana == self.MAJOR: if self.arcana == self.MAJOR:
return '' return ''
return { return {
@@ -281,6 +289,7 @@ class TarotCard(models.Model):
self.CUPS: 'fa-trophy', self.CUPS: 'fa-trophy',
self.SWORDS: 'fa-gun', self.SWORDS: 'fa-gun',
self.PENTACLES: 'fa-star', self.PENTACLES: 'fa-star',
self.CROWNS: 'fa-crown',
}.get(self.suit, '') }.get(self.suit, '')
def __str__(self): def __str__(self):
@@ -324,28 +333,59 @@ 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):
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2). """Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
PC/BC pair → WANDS + PENTACLES court cards (numbers 1114): 8 unique PC/BC pair → WANDS + CROWNS Middle Arcana court cards (1114): 8 unique
SC/AC pair → SWORDS + CUPS court cards (numbers 1114): 8 unique SC/AC pair → SWORDS + CUPS Middle Arcana court cards (1114): 8 unique
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
Total: 18 unique × 2 (levity + gravity piles) = 36 cards. Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
""" """
deck_variant = room.owner.equipped_deck deck_variant = room.owner.equipped_deck
if deck_variant is None: if deck_variant is None:
return [] return []
wands_pentacles = list(TarotCard.objects.filter( wands_crowns = list(TarotCard.objects.filter(
deck_variant=deck_variant, deck_variant=deck_variant,
arcana=TarotCard.MINOR, arcana=TarotCard.MIDDLE,
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES], suit__in=[TarotCard.WANDS, TarotCard.CROWNS],
number__in=[11, 12, 13, 14], number__in=[11, 12, 13, 14],
)) ))
swords_cups = list(TarotCard.objects.filter( swords_cups = list(TarotCard.objects.filter(
deck_variant=deck_variant, deck_variant=deck_variant,
arcana=TarotCard.MINOR, arcana=TarotCard.MIDDLE,
suit__in=[TarotCard.SWORDS, TarotCard.CUPS], suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
number__in=[11, 12, 13, 14], number__in=[11, 12, 13, 14],
)) ))
@@ -354,10 +394,45 @@ def sig_deck_cards(room):
arcana=TarotCard.MAJOR, arcana=TarotCard.MAJOR,
number__in=[0, 1], number__in=[0, 1],
)) ))
unique_cards = wands_pentacles + swords_cups + major # 18 unique unique_cards = wands_crowns + swords_cups + major # 18 unique
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_crowns = list(TarotCard.objects.filter(
deck_variant=deck_variant,
arcana=TarotCard.MIDDLE,
suit__in=[TarotCard.WANDS, TarotCard.CROWNS],
number__in=[11, 12, 13, 14],
))
swords_cups = list(TarotCard.objects.filter(
deck_variant=deck_variant,
arcana=TarotCard.MIDDLE,
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_crowns + 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,73 @@
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;
var isLandscape = vw > vh;
// Tray handle: portrait → vertical strip on right; landscape → tray is easily
// dismissed, so skip the bottomInset calculation (would over-shrink the modal).
var trayHandle = document.getElementById('id_tray_handle');
if (trayHandle && !isLandscape) {
var hr = trayHandle.getBoundingClientRect();
if (hr.width < hr.height) {
// Portrait: handle strips the right edge
rightInset = vw - hr.left;
}
}
// Gear / kit buttons: update right inset if near right edge.
// In landscape they sit at bottom-right but are also dismissible — skip bottomInset.
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);
}
if (!isLandscape && br.bottom > vh - 30) {
bottomInset = Math.max(bottomInset, vh - br.top);
}
});
// Landscape: right clears gear/kit buttons (~4rem); bottom is fixed 60px for
// the kit-bag handle strip — tray is ignored so the stage has room to breathe.
if (isLandscape) {
rightInset = Math.max(rightInset, 64); // 4rem
bottomInset = 60; // kit-bag handle
}
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 +94,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 ──────────────────────────────────────────────────────────
return {
_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,
) )
@@ -273,9 +276,9 @@ class SigDeckCompositionTest(TestCase):
self.assertEqual(len(sc_ac), 16) self.assertEqual(len(sc_ac), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac)) self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
def test_pc_bc_contribute_court_cards_of_wands_and_pentacles(self): def test_pc_bc_contribute_court_cards_of_wands_and_crowns(self):
cards = sig_deck_cards(self.room) cards = sig_deck_cards(self.room)
pc_bc = [c for c in cards if c.suit in ("WANDS", "PENTACLES")] pc_bc = [c for c in cards if c.suit in ("WANDS", "CROWNS")]
self.assertEqual(len(pc_bc), 16) self.assertEqual(len(pc_bc), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc)) self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
@@ -339,7 +342,7 @@ class SigCardFieldTest(TestCase):
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
) )
self.card = TarotCard.objects.get( self.card = TarotCard.objects.get(
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11, deck_variant=earthman, arcana="MIDDLE", suit="WANDS", number=11,
) )
owner = User.objects.create(email="owner@test.io") owner = User.objects.create(email="owner@test.io")
room = Room.objects.create(name="Field Test", owner=owner) room = Room.objects.create(name="Field Test", owner=owner)
@@ -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,
) )
@@ -926,7 +926,7 @@ def _full_sig_setUp(test_case, role_order=None):
room.table_status = Room.SIG_SELECT room.table_status = Room.SIG_SELECT
room.save() room.save()
card_in_deck = TarotCard.objects.get( card_in_deck = TarotCard.objects.get(
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11 deck_variant=earthman, arcana="MIDDLE", suit="WANDS", number=11
) )
test_case.client.force_login(founder) test_case.client.force_login(founder)
return room, gamers, earthman, card_in_deck return room, gamers, earthman, card_in_deck
@@ -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,164 @@ 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="MIDDLE", 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="MIDDLE", 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()
def test_release_broadcasts_card_id_so_second_browser_can_clear_it(self):
"""WS release event must include the card_id; otherwise the receiving
browser can't find the card element to remove .sig-reserved--own."""
self._reserve()
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
self._reserve(action="release")
args, kwargs = mock_notify.call_args
self.assertEqual(args[1], self.card.pk) # card_id must not be None
self.assertFalse(kwargs['reserved']) # reserved=False

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,62 @@ 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":
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
released_card_id = existing.card_id if existing else None
SigReservation.objects.filter(room=room, gamer=request.user).delete()
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
return HttpResponse(status=200)
# 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

@@ -150,7 +150,7 @@ def tarot_fan(request, deck_id):
deck = get_object_or_404(DeckVariant, pk=deck_id) deck = get_object_or_404(DeckVariant, pk=deck_id)
if not request.user.unlocked_decks.filter(pk=deck_id).exists(): if not request.user.unlocked_decks.filter(pk=deck_id).exists():
return HttpResponse(status=403) return HttpResponse(status=403)
_suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "COINS": 4} _suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "CROWNS": 3, "COINS": 4}
cards = sorted( cards = sorted(
TarotCard.objects.filter(deck_variant=deck), TarotCard.objects.filter(deck_variant=deck),
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number), key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),

View File

@@ -0,0 +1,255 @@
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(() => {
if (typeof TouchEvent === 'undefined') { pending('TouchEvent unavailable in desktop Firefox'); return; }
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(() => {
if (typeof TouchEvent === 'undefined') { pending('TouchEvent unavailable in desktop Firefox'); return; }
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", () => {
if (typeof TouchEvent === 'undefined') { pending('TouchEvent unavailable in desktop Firefox'); return; }
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);
});
});
// ── WS release clears NVM in a second browser ─────────────────────── //
// Simulates the same gamer having two tabs open: tab B must clear its
// .sig-reserved--own when tab A presses NVM (WS release event arrives).
// The release payload must carry the card_id so the JS can find the element.
describe("WS release event (second-browser NVM sync)", () => {
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
it("removes .sig-reserved and .sig-reserved--own on WS release", () => {
// Confirm reservation was applied on init
expect(card.classList.contains("sig-reserved--own")).toBe(true);
expect(card.classList.contains("sig-reserved")).toBe(true);
// Tab A presses NVM — tab B receives this WS event with the card_id
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false },
}));
expect(card.classList.contains("sig-reserved--own")).toBe(false);
expect(card.classList.contains("sig-reserved")).toBe(false);
});
it("unfreezes the stage so other cards can be focused after WS release", () => {
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false },
}));
// Should now be able to click the card body again
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

@@ -119,8 +119,8 @@
.room-page, .room-page,
.billboard-page { .billboard-page {
> .gear-btn { > .gear-btn {
right: calc(#{$sidebar-w} + 0.5rem); right: 0.5rem;
bottom: 4.2rem; // same gap above kit btn as portrait; no page-specific overrides needed bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed
top: auto; top: auto;
} }
} }

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

@@ -4,8 +4,8 @@
right: 0.5rem; right: 0.5rem;
@media (orientation: landscape) and (max-width: 1440px) { @media (orientation: landscape) and (max-width: 1440px) {
right: calc(4rem + 0.5rem); right: 0.5rem;
bottom: 0.75rem; bottom: 0.5rem;
top: auto; top: auto;
} }

View File

@@ -817,71 +817,252 @@ $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;
padding: 0.75rem; z-index: 120;
overflow-y: auto; pointer-events: none;
align-content: flex-start; }
max-height: 45vh;
scrollbar-width: thin; .sig-modal {
scrollbar-color: rgba(var(--terUser), 0.3) transparent; 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-left: 1.5rem;
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 — same height as the preview card; fills remaining stage width.
// Height derived from the JS-computed --sig-card-w via aspect ratio 5:8.
.sig-stat-block {
flex: 1;
height: calc(var(--sig-card-w, 120px) * 8 / 5);
align-self: flex-end;
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 fills full available width.
// padding-left clears the fixed left navbar (JS sets right/bottom but not left).
// Grid margins reset to 0 — overlay padding handles all edge clearance in landscape.
@media (orientation: landscape) {
.sig-overlay { padding-left: 4rem; }
.sig-modal { max-width: none; }
.sig-deck-grid {
grid-template-columns: repeat(9, 1fr);
margin: 0;
} }
} }

View File

@@ -0,0 +1,255 @@
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(() => {
if (typeof TouchEvent === 'undefined') { pending('TouchEvent unavailable in desktop Firefox'); return; }
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(() => {
if (typeof TouchEvent === 'undefined') { pending('TouchEvent unavailable in desktop Firefox'); return; }
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", () => {
if (typeof TouchEvent === 'undefined') { pending('TouchEvent unavailable in desktop Firefox'); return; }
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);
});
});
// ── WS release clears NVM in a second browser ─────────────────────── //
// Simulates the same gamer having two tabs open: tab B must clear its
// .sig-reserved--own when tab A presses NVM (WS release event arrives).
// The release payload must carry the card_id so the JS can find the element.
describe("WS release event (second-browser NVM sync)", () => {
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
it("removes .sig-reserved and .sig-reserved--own on WS release", () => {
// Confirm reservation was applied on init
expect(card.classList.contains("sig-reserved--own")).toBe(true);
expect(card.classList.contains("sig-reserved")).toBe(true);
// Tab A presses NVM — tab B receives this WS event with the card_id
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false },
}));
expect(card.classList.contains("sig-reserved--own")).toBe(false);
expect(card.classList.contains("sig-reserved")).toBe(false);
});
it("unfreezes the stage so other cards can be focused after WS release", () => {
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
detail: { card_id: 42, role: "PC", reserved: false },
}));
// Should now be able to click the card body again
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 %}