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:
@@ -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)
|
||||||
|
|||||||
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
var rank = cardEl.dataset.cornerRank || '';
|
||||||
|
var icon = cardEl.dataset.suitIcon || '';
|
||||||
|
var group = cardEl.dataset.nameGroup || '';
|
||||||
|
var title = cardEl.dataset.nameTitle || '';
|
||||||
|
var arcana= cardEl.dataset.arcana || '';
|
||||||
|
var corr = cardEl.dataset.correspondence || '';
|
||||||
|
|
||||||
|
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
|
||||||
|
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||||
|
if (icon) {
|
||||||
|
el.className = 'fa-solid ' + icon + ' stage-suit-icon';
|
||||||
|
el.style.display = '';
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stageCard.querySelector('.fan-card-name-group').textContent = group;
|
||||||
|
stageCard.querySelector('.fan-card-name').textContent = title;
|
||||||
|
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||||
|
stageCard.querySelector('.fan-card-correspondence').textContent = corr;
|
||||||
|
|
||||||
|
stageCard.style.display = '';
|
||||||
|
stage.classList.add('sig-stage--active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance active to next seat
|
// ── Focus a card (click/tap) — shows OK overlay on the card ──────────
|
||||||
var nextRole = getActiveRole();
|
|
||||||
if (nextRole) {
|
function focusCard(cardEl) {
|
||||||
var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]');
|
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||||
if (nextSeat) nextSeat.classList.add('active');
|
if (c !== cardEl) c.classList.remove('sig-focused');
|
||||||
|
});
|
||||||
|
cardEl.classList.add('sig-focused');
|
||||||
|
updateStage(cardEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place a card placeholder in inventory
|
// ── Hover events ──────────────────────────────────────────────────────
|
||||||
var invSlot = document.getElementById('id_inv_sig_card');
|
|
||||||
if (invSlot) {
|
function onCardEnter(e) {
|
||||||
var card = document.createElement('div');
|
var card = e.currentTarget;
|
||||||
card.className = 'card';
|
if (!_stageFrozen) updateStage(card);
|
||||||
invSlot.appendChild(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; },
|
||||||
|
};
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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), [])
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
215
src/static/tests/SigSelectSpec.js
Normal file
215
src/static/tests/SigSelectSpec.js
Normal 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, '"')}">
|
||||||
|
<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
padding: 0.75rem;
|
z-index: 120;
|
||||||
overflow-y: auto;
|
pointer-events: none;
|
||||||
align-content: flex-start;
|
|
||||||
max-height: 45vh;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(var(--terUser), 0.3) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sig-card {
|
.sig-modal {
|
||||||
width: 70px;
|
pointer-events: auto;
|
||||||
height: 108px;
|
display: flex;
|
||||||
border-radius: 0.4rem;
|
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;
|
||||||
|
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);
|
background: rgba(var(--priUser), 1);
|
||||||
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem 0.15rem;
|
||||||
cursor: pointer;
|
gap: 0.2rem;
|
||||||
transition: transform 0.15s, border-color 0.15s;
|
|
||||||
|
.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 {
|
||||||
|
aspect-ratio: 5 / 8;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(var(--priUser), 0.97);
|
||||||
|
border: 1px solid rgba(var(--secUser), 0.3);
|
||||||
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 ─────────────────────────────────────────────
|
||||||
|
|||||||
215
src/static_src/tests/SigSelectSpec.js
Normal file
215
src/static_src/tests/SigSelectSpec.js
Normal 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, '"')}">
|
||||||
|
<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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" %}
|
||||||
|
|||||||
Reference in New Issue
Block a user