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)
|
||||
|
||||
async def receive_json(self, content):
|
||||
if content.get("type") == "cursor_move" and self.cursor_group:
|
||||
msg_type = content.get("type")
|
||||
if msg_type == "cursor_move" and self.cursor_group:
|
||||
await self.channel_layer.group_send(
|
||||
self.cursor_group,
|
||||
{"type": "cursor_move", "x": content.get("x"), "y": content.get("y")},
|
||||
)
|
||||
elif msg_type == "sig_hover" and self.cursor_group:
|
||||
await self.channel_layer.group_send(
|
||||
self.cursor_group,
|
||||
{
|
||||
"type": "sig_hover",
|
||||
"card_id": content.get("card_id"),
|
||||
"role": content.get("role"),
|
||||
"active": content.get("active"),
|
||||
},
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def _get_seat(self, user):
|
||||
@@ -61,5 +72,11 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def sig_selected(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_hover(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_reserved(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def cursor_move(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
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 django.db import models
|
||||
from django.db.models import UniqueConstraint
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
@@ -324,6 +325,37 @@ class TarotDeck(models.Model):
|
||||
self.save(update_fields=["drawn_card_ids"])
|
||||
|
||||
|
||||
# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────
|
||||
|
||||
class SigReservation(models.Model):
|
||||
LEVITY = 'levity'
|
||||
GRAVITY = 'gravity'
|
||||
POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]
|
||||
|
||||
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
|
||||
gamer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
|
||||
)
|
||||
card = models.ForeignKey(
|
||||
'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
|
||||
)
|
||||
role = models.CharField(max_length=2)
|
||||
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
|
||||
reserved_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
UniqueConstraint(
|
||||
fields=['room', 'gamer'],
|
||||
name='one_sig_reservation_per_gamer_per_room',
|
||||
),
|
||||
UniqueConstraint(
|
||||
fields=['room', 'card', 'polarity'],
|
||||
name='one_reservation_per_card_per_polarity_per_room',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||
|
||||
def sig_deck_cards(room):
|
||||
@@ -358,6 +390,41 @@ def sig_deck_cards(room):
|
||||
return unique_cards + unique_cards # × 2 = 36
|
||||
|
||||
|
||||
def _sig_unique_cards(room):
|
||||
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
||||
deck_variant = room.owner.equipped_deck
|
||||
if deck_variant is None:
|
||||
return []
|
||||
wands_pentacles = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MINOR,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
swords_cups = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MINOR,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
major = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MAJOR,
|
||||
number__in=[0, 1],
|
||||
))
|
||||
return wands_pentacles + swords_cups + major
|
||||
|
||||
|
||||
def levity_sig_cards(room):
|
||||
"""The 18 cards available to the levity group (PC/NC/SC)."""
|
||||
return _sig_unique_cards(room)
|
||||
|
||||
|
||||
def gravity_sig_cards(room):
|
||||
"""The 18 cards available to the gravity group (BC/EC/AC)."""
|
||||
return _sig_unique_cards(room)
|
||||
|
||||
|
||||
def sig_seat_order(room):
|
||||
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
|
||||
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
|
||||
|
||||
@@ -20,6 +20,62 @@
|
||||
window.addEventListener('resize', scaleTable);
|
||||
}());
|
||||
|
||||
(function () {
|
||||
// Size the sig-select overlay so the card grid clears the tray handle
|
||||
// (portrait: right strip 48px; landscape: bottom strip 48px) and any
|
||||
// fixed gear/kit buttons that protrude further into the viewport.
|
||||
// Mirrors the scaleTable() pattern — runs on load (after tray.js has
|
||||
// positioned the tray) and on every resize.
|
||||
function sizeSigModal() {
|
||||
var overlay = document.querySelector('.sig-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
var rightInset = 0;
|
||||
var bottomInset = 0;
|
||||
|
||||
// Tray handle: portrait → vertical strip on right; landscape → horizontal at bottom
|
||||
var trayHandle = document.getElementById('id_tray_handle');
|
||||
if (trayHandle) {
|
||||
var hr = trayHandle.getBoundingClientRect();
|
||||
if (hr.width >= hr.height) {
|
||||
// Landscape: handle spans the bottom
|
||||
bottomInset = vh - hr.top;
|
||||
} else {
|
||||
// Portrait: handle strips the right edge
|
||||
rightInset = vw - hr.left;
|
||||
}
|
||||
}
|
||||
|
||||
// Gear / kit buttons fixed at the right edge may protrude left of the tray handle
|
||||
document.querySelectorAll('.room-page > .gear-btn').forEach(function (btn) {
|
||||
var br = btn.getBoundingClientRect();
|
||||
if (br.right > vw - 30) {
|
||||
rightInset = Math.max(rightInset, vw - br.left);
|
||||
}
|
||||
});
|
||||
|
||||
overlay.style.paddingRight = rightInset + 'px';
|
||||
overlay.style.paddingBottom = bottomInset + 'px';
|
||||
|
||||
// Stage card: smaller of 40% stage width OR (80% stage height × 5/8 aspect).
|
||||
// libsass can't handle cqw/cqh inside min(), so we compute it here.
|
||||
var stageEl = overlay.querySelector('.sig-stage');
|
||||
if (stageEl) {
|
||||
var sw = stageEl.offsetWidth - 24; // subtract padding (0.75rem × 2)
|
||||
var sh = stageEl.offsetHeight - 24;
|
||||
if (sw > 0 && sh > 0) {
|
||||
var cardW = Math.min(sw * 0.4, sh * 0.8 * 5 / 8);
|
||||
overlay.style.setProperty('--sig-card-w', cardW + 'px');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', sizeSigModal);
|
||||
window.addEventListener('resize', sizeSigModal);
|
||||
}());
|
||||
|
||||
(function () {
|
||||
const roomPage = document.querySelector('.room-page');
|
||||
if (!roomPage) return;
|
||||
@@ -27,6 +83,7 @@
|
||||
const roomId = roomPage.dataset.roomId;
|
||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
||||
window._roomSocket = ws; // exposed for sig-select.js hover broadcast
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
@@ -1,96 +1,272 @@
|
||||
var SigSelect = (function () {
|
||||
var SIG_ORDER = ['PC', 'NC', 'EC', 'SC', 'AC', 'BC'];
|
||||
// Polarity → three roles in fixed left/mid/right cursor order
|
||||
var POLARITY_ROLES = {
|
||||
levity: ['PC', 'NC', 'SC'],
|
||||
gravity: ['BC', 'EC', 'AC'],
|
||||
};
|
||||
|
||||
var sigDeck, selectUrl, userRole;
|
||||
var overlay, deckGrid, stage, stageCard;
|
||||
var reserveUrl, userRole, userPolarity;
|
||||
|
||||
function getActiveRole() {
|
||||
for (var i = 0; i < SIG_ORDER.length; i++) {
|
||||
var seat = document.querySelector('.table-seat[data-role="' + SIG_ORDER[i] + '"]');
|
||||
if (seat && !seat.dataset.sigDone) return SIG_ORDER[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEligible() {
|
||||
return !!(userRole && userRole === getActiveRole());
|
||||
}
|
||||
var _focusedCardEl = null; // card currently shown in stage
|
||||
var _reservedCardId = null; // card with active reservation
|
||||
var _stageFrozen = false; // true after OK — stage locks on reserved card
|
||||
var _requestInFlight = false;
|
||||
|
||||
function getCsrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function applySelection(cardId, role, deckType) {
|
||||
// Remove only the specific pile copy (levity or gravity) of this card
|
||||
var selector = '.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]';
|
||||
sigDeck.querySelectorAll(selector).forEach(function (c) { c.remove(); });
|
||||
// ── Stage ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Mark this seat done, remove active
|
||||
var seat = document.querySelector('.table-seat[data-role="' + role + '"]');
|
||||
if (seat) {
|
||||
seat.classList.remove('active');
|
||||
seat.dataset.sigDone = '1';
|
||||
function updateStage(cardEl) {
|
||||
if (_stageFrozen) return;
|
||||
if (!cardEl) {
|
||||
stageCard.style.display = 'none';
|
||||
stage.classList.remove('sig-stage--active');
|
||||
_focusedCardEl = null;
|
||||
return;
|
||||
}
|
||||
_focusedCardEl = cardEl;
|
||||
|
||||
// Advance active to next seat
|
||||
var nextRole = getActiveRole();
|
||||
if (nextRole) {
|
||||
var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]');
|
||||
if (nextSeat) nextSeat.classList.add('active');
|
||||
}
|
||||
var rank = cardEl.dataset.cornerRank || '';
|
||||
var icon = cardEl.dataset.suitIcon || '';
|
||||
var group = cardEl.dataset.nameGroup || '';
|
||||
var title = cardEl.dataset.nameTitle || '';
|
||||
var arcana= cardEl.dataset.arcana || '';
|
||||
var corr = cardEl.dataset.correspondence || '';
|
||||
|
||||
// Place a card placeholder in inventory
|
||||
var invSlot = document.getElementById('id_inv_sig_card');
|
||||
if (invSlot) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
invSlot.appendChild(card);
|
||||
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
|
||||
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||
if (icon) {
|
||||
el.className = 'fa-solid ' + icon + ' stage-suit-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
stageCard.querySelector('.fan-card-name-group').textContent = group;
|
||||
stageCard.querySelector('.fan-card-name').textContent = title;
|
||||
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||
stageCard.querySelector('.fan-card-correspondence').textContent = corr;
|
||||
|
||||
stageCard.style.display = '';
|
||||
stage.classList.add('sig-stage--active');
|
||||
}
|
||||
|
||||
// ── Focus a card (click/tap) — shows OK overlay on the card ──────────
|
||||
|
||||
function focusCard(cardEl) {
|
||||
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||
if (c !== cardEl) c.classList.remove('sig-focused');
|
||||
});
|
||||
cardEl.classList.add('sig-focused');
|
||||
updateStage(cardEl);
|
||||
}
|
||||
|
||||
// ── Hover events ──────────────────────────────────────────────────────
|
||||
|
||||
function onCardEnter(e) {
|
||||
var card = e.currentTarget;
|
||||
if (!_stageFrozen) updateStage(card);
|
||||
sendHover(card.dataset.cardId, true);
|
||||
}
|
||||
|
||||
function onCardLeave(e) {
|
||||
if (!_stageFrozen) updateStage(null);
|
||||
sendHover(e.currentTarget.dataset.cardId, false);
|
||||
}
|
||||
|
||||
// ── Reserve / release ─────────────────────────────────────────────────
|
||||
|
||||
function doReserve(cardEl) {
|
||||
if (_requestInFlight) return;
|
||||
var cardId = cardEl.dataset.cardId;
|
||||
_requestInFlight = true;
|
||||
fetch(reserveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=reserve&card_id=' + encodeURIComponent(cardId),
|
||||
}).then(function (res) {
|
||||
_requestInFlight = false;
|
||||
if (res.ok) applyReservation(cardId, userRole, true);
|
||||
}).catch(function () { _requestInFlight = false; });
|
||||
}
|
||||
|
||||
function doRelease() {
|
||||
if (_requestInFlight || !_reservedCardId) return;
|
||||
var cardId = _reservedCardId;
|
||||
_requestInFlight = true;
|
||||
fetch(reserveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=release&card_id=' + encodeURIComponent(cardId),
|
||||
}).then(function (res) {
|
||||
_requestInFlight = false;
|
||||
if (res.ok) applyReservation(cardId, userRole, false);
|
||||
}).catch(function () { _requestInFlight = false; });
|
||||
}
|
||||
|
||||
// ── Apply reservation state (local + from WS) ─────────────────────────
|
||||
|
||||
function applyReservation(cardId, role, reserved) {
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
|
||||
if (reserved) {
|
||||
cardEl.dataset.reservedBy = role;
|
||||
cardEl.classList.add('sig-reserved');
|
||||
if (role === userRole) {
|
||||
_reservedCardId = cardId;
|
||||
cardEl.classList.add('sig-reserved--own');
|
||||
cardEl.classList.remove('sig-focused');
|
||||
// Freeze stage on this card (temporarily unfreeze to populate it)
|
||||
_stageFrozen = false;
|
||||
updateStage(cardEl);
|
||||
_stageFrozen = true;
|
||||
stage.classList.add('sig-stage--frozen');
|
||||
}
|
||||
} else {
|
||||
delete cardEl.dataset.reservedBy;
|
||||
cardEl.classList.remove('sig-reserved', 'sig-reserved--own');
|
||||
if (role === userRole) {
|
||||
_reservedCardId = null;
|
||||
_stageFrozen = false;
|
||||
stage.classList.remove('sig-stage--frozen');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply hover cursor (WS only — own hover is CSS :hover) ────────────
|
||||
|
||||
function applyHover(cardId, role, active) {
|
||||
if (role === userRole) return;
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
|
||||
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||
var idx = roles.indexOf(role);
|
||||
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||
var cursor = cardEl.querySelector('.sig-cursor' + posClass);
|
||||
if (!cursor) return;
|
||||
|
||||
if (active) {
|
||||
cursor.classList.add('active');
|
||||
} else {
|
||||
cursor.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// ── WS events ─────────────────────────────────────────────────────────
|
||||
|
||||
window.addEventListener('room:sig_reserved', function (e) {
|
||||
if (!deckGrid) return;
|
||||
applyReservation(String(e.detail.card_id), e.detail.role, e.detail.reserved);
|
||||
});
|
||||
|
||||
window.addEventListener('room:sig_hover', function (e) {
|
||||
if (!deckGrid) return;
|
||||
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
||||
});
|
||||
|
||||
// ── WS send ───────────────────────────────────────────────────────────
|
||||
|
||||
function sendHover(cardId, active) {
|
||||
if (!window._roomSocket || window._roomSocket.readyState !== WebSocket.OPEN) return;
|
||||
window._roomSocket.send(JSON.stringify({
|
||||
type: 'sig_hover', card_id: cardId, role: userRole, active: active,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
sigDeck = document.getElementById('id_sig_deck');
|
||||
if (!sigDeck) return;
|
||||
selectUrl = sigDeck.dataset.selectSigUrl;
|
||||
userRole = sigDeck.dataset.userRole;
|
||||
overlay = document.querySelector('.sig-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
sigDeck.addEventListener('click', function (e) {
|
||||
deckGrid = overlay.querySelector('.sig-deck-grid');
|
||||
stage = overlay.querySelector('.sig-stage');
|
||||
stageCard = stage.querySelector('.sig-stage-card');
|
||||
|
||||
reserveUrl = overlay.dataset.reserveUrl;
|
||||
userRole = overlay.dataset.userRole;
|
||||
userPolarity= overlay.dataset.polarity;
|
||||
|
||||
// Restore reservations from server-rendered JSON (page-load state)
|
||||
try {
|
||||
var existing = JSON.parse(overlay.dataset.reservations || '{}');
|
||||
Object.keys(existing).forEach(function (cardId) {
|
||||
applyReservation(cardId, existing[cardId], true);
|
||||
});
|
||||
} catch (e) { /* malformed JSON — ignore */ }
|
||||
|
||||
// Hover: update stage preview + broadcast cursor
|
||||
deckGrid.querySelectorAll('.sig-card').forEach(function (card) {
|
||||
card.addEventListener('mouseenter', onCardEnter);
|
||||
card.addEventListener('mouseleave', onCardLeave);
|
||||
card.addEventListener('touchstart', function (e) {
|
||||
var card = e.currentTarget;
|
||||
if (_reservedCardId) return; // locked until NVM — no preventDefault either
|
||||
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||
if (reservedByOther || isOwnReserved) return;
|
||||
// If the tap is on the OK button, let the synthetic click fire normally
|
||||
if (e.target.closest('.sig-ok-btn')) return;
|
||||
focusCard(card);
|
||||
e.preventDefault(); // prevent ghost click on card body
|
||||
}, { passive: false });
|
||||
});
|
||||
|
||||
// Touch outside the grid — dismiss stage preview (unfocused state only).
|
||||
// Card touchstart doesn't stop propagation, so we guard with closest().
|
||||
overlay.addEventListener('touchstart', function (e) {
|
||||
if (_stageFrozen || !_focusedCardEl) return;
|
||||
if (e.target.closest('.sig-deck-grid')) return;
|
||||
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||
c.classList.remove('sig-focused');
|
||||
});
|
||||
updateStage(null);
|
||||
}, { passive: true });
|
||||
|
||||
// Click delegation: card body → focus (shows OK); OK/NVM buttons → reserve/release
|
||||
deckGrid.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.sig-ok-btn')) {
|
||||
if (_reservedCardId) return; // already holding — must NVM first
|
||||
var card = e.target.closest('.sig-card');
|
||||
if (card) doReserve(card);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.sig-nvm-btn')) {
|
||||
doRelease();
|
||||
return;
|
||||
}
|
||||
var card = e.target.closest('.sig-card');
|
||||
if (!card) return;
|
||||
if (!isEligible()) return;
|
||||
var activeRole = getActiveRole();
|
||||
var cardId = card.dataset.cardId;
|
||||
var deckType = card.dataset.deck;
|
||||
window.showGuard(card, 'Select this significator?', function () {
|
||||
fetch(selectUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': getCsrf(),
|
||||
},
|
||||
body: 'card_id=' + encodeURIComponent(cardId) + '&deck_type=' + encodeURIComponent(deckType),
|
||||
}).then(function (response) {
|
||||
if (response.ok) {
|
||||
applySelection(cardId, activeRole, deckType);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (_reservedCardId) return; // locked until NVM
|
||||
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||
if (reservedByOther || isOwnReserved) return;
|
||||
focusCard(card);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('room:sig_selected', function (e) {
|
||||
if (!sigDeck) return;
|
||||
var cardId = String(e.detail.card_id);
|
||||
var role = e.detail.role;
|
||||
var deckType = e.detail.deck_type;
|
||||
// Idempotent — skip if this copy already removed (local selector already did it)
|
||||
if (!sigDeck.querySelector('.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]')) return;
|
||||
applySelection(cardId, role, deckType);
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// ── Test API ──────────────────────────────────────────────────────────
|
||||
window.SigSelect = {
|
||||
_testInit: function () {
|
||||
_focusedCardEl = null;
|
||||
_reservedCardId = null;
|
||||
_stageFrozen = false;
|
||||
_requestInFlight = false;
|
||||
init();
|
||||
},
|
||||
_setFrozen: function (v) { _stageFrozen = v; },
|
||||
_setReservedCardId: function (id) { _reservedCardId = id; },
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -164,3 +164,98 @@ class CursorMoveConsumerTest(TransactionTestCase):
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await bc_comm.disconnect()
|
||||
|
||||
|
||||
@tag('channels')
|
||||
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||
class SigHoverConsumerTest(TransactionTestCase):
|
||||
"""sig_hover messages sent by a client are forwarded within the polarity group only."""
|
||||
|
||||
async def _make_communicator(self, user, room):
|
||||
client = Client()
|
||||
await database_sync_to_async(client.force_login)(user)
|
||||
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||
comm = WebsocketCommunicator(
|
||||
application,
|
||||
f"/ws/room/{room.id}/",
|
||||
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||
)
|
||||
connected, _ = await comm.connect()
|
||||
self.assertTrue(connected)
|
||||
return comm
|
||||
|
||||
async def test_sig_hover_forwarded_to_polarity_group(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||
)
|
||||
|
||||
pc_comm = await self._make_communicator(pc_user, room)
|
||||
nc_comm = await self._make_communicator(nc_user, room)
|
||||
|
||||
await pc_comm.send_json_to({
|
||||
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||
})
|
||||
|
||||
msg = await nc_comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["type"], "sig_hover")
|
||||
self.assertEqual(msg["card_id"], "abc-123")
|
||||
self.assertEqual(msg["role"], "PC")
|
||||
self.assertTrue(msg["active"])
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await nc_comm.disconnect()
|
||||
|
||||
async def test_sig_hover_not_forwarded_to_other_polarity(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=bc_user, slot_number=2, role="BC"
|
||||
)
|
||||
|
||||
pc_comm = await self._make_communicator(pc_user, room)
|
||||
bc_comm = await self._make_communicator(bc_user, room)
|
||||
|
||||
await pc_comm.send_json_to({
|
||||
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||
})
|
||||
|
||||
self.assertTrue(await bc_comm.receive_nothing(timeout=1))
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await bc_comm.disconnect()
|
||||
|
||||
async def test_sig_reserved_broadcast_received_by_polarity_group(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||
)
|
||||
|
||||
nc_comm = await self._make_communicator(nc_user, room)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f"cursors_{room.id}_levity",
|
||||
{"type": "sig_reserved", "card_id": "card-xyz", "role": "PC", "reserved": True},
|
||||
)
|
||||
|
||||
msg = await nc_comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["type"], "sig_reserved")
|
||||
self.assertEqual(msg["card_id"], "card-xyz")
|
||||
self.assertTrue(msg["reserved"])
|
||||
|
||||
await nc_comm.disconnect()
|
||||
|
||||
@@ -4,10 +4,13 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat,
|
||||
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
|
||||
sig_seat_order, active_sig_seat,
|
||||
)
|
||||
|
||||
|
||||
@@ -360,3 +363,119 @@ class SigCardFieldTest(TestCase):
|
||||
self.card.delete()
|
||||
self.seat.refresh_from_db()
|
||||
self.assertIsNone(self.seat.significator)
|
||||
|
||||
|
||||
# ── SigReservation model ──────────────────────────────────────────────────────
|
||||
|
||||
def _make_sig_card(deck_variant, suit, number):
|
||||
name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
card, _ = TarotCard.objects.get_or_create(
|
||||
deck_variant=deck_variant,
|
||||
slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={
|
||||
"arcana": "MINOR", "suit": suit, "number": number,
|
||||
"name": f"{name_map[number]} of {suit.capitalize()}",
|
||||
},
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
class SigReservationModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.room = Room.objects.create(name="Sig Room", owner=self.owner)
|
||||
self.card = _make_sig_card(self.earthman, "WANDS", 14)
|
||||
self.seat = TableSeat.objects.create(
|
||||
room=self.room, gamer=self.owner, slot_number=1, role="PC"
|
||||
)
|
||||
|
||||
def test_can_create_sig_reservation(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
self.assertEqual(res.role, "PC")
|
||||
self.assertEqual(res.polarity, "levity")
|
||||
self.assertIsNotNone(res.reserved_at)
|
||||
|
||||
def test_one_reservation_per_gamer_per_room(self):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
card2 = _make_sig_card(self.earthman, "CUPS", 13)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_blocked_within_same_polarity(self):
|
||||
gamer2 = User.objects.create(email="nc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_allowed_across_polarity(self):
|
||||
"""A gravity gamer may reserve the same card instance as a levity gamer
|
||||
— each polarity has its own independent pile."""
|
||||
gamer2 = User.objects.create(email="bc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res2 = SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
|
||||
)
|
||||
self.assertIsNotNone(res2.pk)
|
||||
|
||||
def test_deleting_reservation_clears_slot(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res.delete()
|
||||
self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())
|
||||
|
||||
|
||||
class SigCardHelperTest(TestCase):
|
||||
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each.
|
||||
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# Earthman deck is already seeded by migrations
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.owner.equipped_deck = self.earthman
|
||||
self.owner.save()
|
||||
self.room = Room.objects.create(name="Card Test", owner=self.owner)
|
||||
|
||||
def test_levity_sig_cards_returns_18(self):
|
||||
cards = levity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_gravity_sig_cards_returns_18(self):
|
||||
cards = gravity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_levity_and_gravity_share_same_card_objects(self):
|
||||
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
||||
comes from CSS polarity class, not separate card model records."""
|
||||
levity = levity_sig_cards(self.room)
|
||||
gravity = gravity_sig_cards(self.room)
|
||||
self.assertEqual(
|
||||
sorted(c.pk for c in levity),
|
||||
sorted(c.pk for c in gravity),
|
||||
)
|
||||
|
||||
def test_returns_empty_when_no_equipped_deck(self):
|
||||
self.owner.equipped_deck = None
|
||||
self.owner.save()
|
||||
self.assertEqual(levity_sig_cards(self.room), [])
|
||||
self.assertEqual(gravity_sig_cards(self.room), [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||
)
|
||||
|
||||
|
||||
@@ -943,9 +943,9 @@ class SigSelectRenderingTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "id_sig_deck")
|
||||
|
||||
def test_sig_deck_contains_36_sig_cards(self):
|
||||
def test_sig_deck_contains_18_sig_cards(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.content.decode().count('sig-card'), 36)
|
||||
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
||||
|
||||
def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
|
||||
response = self.client.get(self.url)
|
||||
@@ -1119,3 +1119,154 @@ class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||||
|
||||
|
||||
# ── sig_reserve view ──────────────────────────────────────────────────────────
|
||||
|
||||
class SigReserveViewTest(TestCase):
|
||||
"""sig_reserve — provisional card hold; OK/NVM flow."""
|
||||
|
||||
def setUp(self):
|
||||
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
||||
# founder (gamers[0]) is PC — levity polarity
|
||||
self.client.force_login(self.gamers[0])
|
||||
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
||||
|
||||
def _reserve(self, card_id=None, action="reserve", client=None):
|
||||
c = client or self.client
|
||||
return c.post(self.url, data={
|
||||
"card_id": card_id or self.card.id,
|
||||
"action": action,
|
||||
})
|
||||
|
||||
# ── happy-path reserve ────────────────────────────────────────────────
|
||||
|
||||
def test_reserve_creates_sig_reservation(self):
|
||||
self._reserve()
|
||||
self.assertTrue(SigReservation.objects.filter(
|
||||
room=self.room, gamer=self.gamers[0], card=self.card
|
||||
).exists())
|
||||
|
||||
def test_reserve_returns_200(self):
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_reservation_has_correct_polarity(self):
|
||||
self._reserve()
|
||||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||
self.assertEqual(res.polarity, "levity")
|
||||
|
||||
def test_gravity_gamer_reservation_has_gravity_polarity(self):
|
||||
# gamers[3] is SC (index 3 → role SC → but _full_sig_setUp uses SIG_SEAT_ORDER
|
||||
# which assigns PC→NC→EC→SC→AC→BC, so slot 4 = SC, slot 5 = AC, slot 6 = BC)
|
||||
# gamers[5] is BC → gravity
|
||||
bc_client = self.client.__class__()
|
||||
bc_client.force_login(self.gamers[5])
|
||||
self._reserve(client=bc_client)
|
||||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[5])
|
||||
self.assertEqual(res.polarity, "gravity")
|
||||
|
||||
# ── conflict handling ─────────────────────────────────────────────────
|
||||
|
||||
def test_reserve_taken_card_same_polarity_returns_409(self):
|
||||
# NC (gamers[1]) reserves the same card first — both are levity
|
||||
nc_client = self.client.__class__()
|
||||
nc_client.force_login(self.gamers[1])
|
||||
self._reserve(client=nc_client)
|
||||
# Now PC tries to grab the same card — should be blocked
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 409)
|
||||
|
||||
def test_reserve_taken_card_cross_polarity_succeeds(self):
|
||||
# BC (gamers[5], gravity) reserves the same card — different polarity, allowed
|
||||
bc_client = self.client.__class__()
|
||||
bc_client.force_login(self.gamers[5])
|
||||
self._reserve(client=bc_client)
|
||||
response = self._reserve() # PC (levity) grabs same card
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_reserve_different_card_while_holding_returns_409(self):
|
||||
"""Cannot OK a different card while holding one — must NVM first."""
|
||||
card_b = TarotCard.objects.filter(
|
||||
deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=12
|
||||
).first()
|
||||
self._reserve() # PC grabs card A → 200
|
||||
response = self._reserve(card_id=card_b.id) # tries card B → 409
|
||||
self.assertEqual(response.status_code, 409)
|
||||
# Original reservation still intact
|
||||
reservations = SigReservation.objects.filter(room=self.room, gamer=self.gamers[0])
|
||||
self.assertEqual(reservations.count(), 1)
|
||||
self.assertEqual(reservations.first().card, self.card)
|
||||
|
||||
def test_reserve_same_card_again_is_idempotent(self):
|
||||
"""Re-POSTing the same card while already holding it returns 200 (no-op)."""
|
||||
self._reserve()
|
||||
response = self._reserve() # same card again
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).count(), 1
|
||||
)
|
||||
|
||||
def test_reserve_blocked_then_unblocked_after_release(self):
|
||||
"""After NVM, a new card can be OK'd."""
|
||||
card_b = TarotCard.objects.filter(
|
||||
deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=12
|
||||
).first()
|
||||
self._reserve() # hold card A
|
||||
self._reserve(action="release") # NVM
|
||||
response = self._reserve(card_id=card_b.id) # now card B → 200
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(SigReservation.objects.filter(
|
||||
room=self.room, gamer=self.gamers[0], card=card_b
|
||||
).exists())
|
||||
|
||||
# ── release ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_release_deletes_reservation(self):
|
||||
self._reserve()
|
||||
self._reserve(action="release")
|
||||
self.assertFalse(SigReservation.objects.filter(
|
||||
room=self.room, gamer=self.gamers[0]
|
||||
).exists())
|
||||
|
||||
def test_release_returns_200(self):
|
||||
self._reserve()
|
||||
response = self._reserve(action="release")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_release_with_no_reservation_still_200(self):
|
||||
"""NVM when nothing held is harmless."""
|
||||
response = self._reserve(action="release")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_reserve_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/accounts/login/", response.url)
|
||||
|
||||
def test_reserve_requires_seated_gamer(self):
|
||||
outsider = User.objects.create(email="outsider@test.io")
|
||||
outsider_client = self.client.__class__()
|
||||
outsider_client.force_login(outsider)
|
||||
response = self._reserve(client=outsider_client)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_reserve_wrong_phase_returns_400(self):
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_reserve_broadcasts_ws(self):
|
||||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||
self._reserve()
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
def test_release_broadcasts_ws(self):
|
||||
self._reserve()
|
||||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||
self._reserve(action="release")
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
@@ -16,6 +16,7 @@ urlpatterns = [
|
||||
path('room/<uuid:room_id>/pick-sigs', views.pick_sigs, name='pick_sigs'),
|
||||
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
||||
path('room/<uuid:room_id>/select-sig', views.select_sig, name='select_sig'),
|
||||
path('room/<uuid:room_id>/sig-reserve', views.sig_reserve, name='sig_reserve'),
|
||||
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -10,8 +11,9 @@ from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import (
|
||||
GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck,
|
||||
active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order,
|
||||
GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, TarotDeck,
|
||||
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
|
||||
select_token, sig_deck_cards,
|
||||
)
|
||||
from apps.lyric.models import Token
|
||||
|
||||
@@ -74,6 +76,20 @@ def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
|
||||
)
|
||||
|
||||
|
||||
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||
|
||||
|
||||
def _notify_sig_reserved(room_id, card_id, role, reserved):
|
||||
"""Broadcast a sig_reserved event to the matching polarity cursor group."""
|
||||
polarity = 'levity' if role in _LEVITY_ROLES else 'gravity'
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'cursors_{room_id}_{polarity}',
|
||||
{'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None,
|
||||
'role': role, 'reserved': reserved},
|
||||
)
|
||||
|
||||
|
||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
|
||||
|
||||
@@ -216,16 +232,35 @@ def _role_select_context(room, user):
|
||||
}
|
||||
if room.table_status == Room.SIG_SELECT:
|
||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
||||
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
|
||||
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
|
||||
user_role = user_seat.role if user_seat else None
|
||||
user_polarity = None
|
||||
if user_role in _LEVITY_ROLES:
|
||||
user_polarity = 'levity'
|
||||
elif user_role in _GRAVITY_ROLES:
|
||||
user_polarity = 'gravity'
|
||||
|
||||
ctx["user_seat"] = user_seat
|
||||
ctx["partner_seat"] = partner_seat
|
||||
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
|
||||
raw_sig_cards = sig_deck_cards(room)
|
||||
half = len(raw_sig_cards) // 2
|
||||
ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]]
|
||||
ctx["sig_seats"] = sig_seat_order(room)
|
||||
ctx["sig_active_seat"] = active_sig_seat(room)
|
||||
ctx["user_polarity"] = user_polarity
|
||||
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
||||
|
||||
# Pre-load existing reservations for this polarity so JS can restore
|
||||
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
|
||||
if user_polarity:
|
||||
polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY
|
||||
reservations = {
|
||||
str(res.card_id): res.role
|
||||
for res in room.sig_reservations.filter(polarity=polarity_const)
|
||||
}
|
||||
else:
|
||||
reservations = {}
|
||||
ctx["sig_reservations_json"] = json.dumps(reservations)
|
||||
|
||||
if user_polarity == 'levity':
|
||||
ctx["sig_cards"] = levity_sig_cards(room)
|
||||
elif user_polarity == 'gravity':
|
||||
ctx["sig_cards"] = gravity_sig_cards(room)
|
||||
else:
|
||||
ctx["sig_cards"] = []
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -526,6 +561,60 @@ def gate_status(request, room_id):
|
||||
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
def sig_reserve(request, room_id):
|
||||
"""Provisional card hold (OK / NVM) during SIG_SELECT.
|
||||
POST body: card_id=<uuid>, action=reserve|release
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
room = Room.objects.get(id=room_id)
|
||||
if room.table_status != Room.SIG_SELECT:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
user_seat = room.table_seats.filter(gamer=request.user).first()
|
||||
if not user_seat or not user_seat.role:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
action = request.POST.get("action", "reserve")
|
||||
|
||||
if action == "release":
|
||||
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
||||
_notify_sig_reserved(room_id, None, user_seat.role, reserved=False)
|
||||
return HttpResponse(status=200)
|
||||
|
||||
# Reserve action
|
||||
card_id = request.POST.get("card_id")
|
||||
try:
|
||||
card = TarotCard.objects.get(pk=card_id)
|
||||
except TarotCard.DoesNotExist:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
|
||||
|
||||
# Block if another gamer in the same polarity already holds this card
|
||||
if SigReservation.objects.filter(
|
||||
room=room, card=card, polarity=polarity
|
||||
).exclude(gamer=request.user).exists():
|
||||
return HttpResponse(status=409)
|
||||
|
||||
# Block if this gamer already holds a *different* card — must NVM first
|
||||
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||
if existing and existing.card != card:
|
||||
return HttpResponse(status=409)
|
||||
|
||||
# Idempotent: already holding the same card
|
||||
if existing:
|
||||
return HttpResponse(status=200)
|
||||
|
||||
SigReservation.objects.create(
|
||||
room=room, gamer=request.user, card=card,
|
||||
role=user_seat.role, polarity=polarity,
|
||||
)
|
||||
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
@login_required
|
||||
def select_sig(request, room_id):
|
||||
if request.method != "POST":
|
||||
|
||||
Reference in New Issue
Block a user