wired PICK SKY server-side polarity countdown via threading.Timer (tasks.py); fixed polarity_done overlay gating on refresh; cleared sig-select floats on overlay dismiss; filtered Redact events from Most Recent applet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
13
CLAUDE.md
13
CLAUDE.md
@@ -97,6 +97,19 @@ python src/manage.py test src/functional_tests
|
|||||||
python src/manage.py test src
|
python src/manage.py test src
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Multi-user manual testing — `setup_sig_session`
|
||||||
|
`src/functional_tests/management/commands/setup_sig_session.py`
|
||||||
|
|
||||||
|
Creates (or reuses) a room with all 6 gate slots filled, roles assigned, and `table_status=SIG_SELECT`. Prints one pre-auth URL per gamer for pasting into 6 Firefox Multi-Account Container tabs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python src/manage.py setup_sig_session
|
||||||
|
python src/manage.py setup_sig_session --base-url http://localhost:8000
|
||||||
|
python src/manage.py setup_sig_session --room <uuid> # reuse existing room
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all created as superusers with Earthman deck equipped. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
|
||||||
|
|
||||||
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
||||||
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
||||||
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ def billboard(request):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
recent_events = (
|
recent_events = (
|
||||||
list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1]
|
list(
|
||||||
|
recent_room.events
|
||||||
|
.select_related("actor")
|
||||||
|
.exclude(verb=GameEvent.SIG_UNREADY)
|
||||||
|
.exclude(verb=GameEvent.SIG_READY, data__retracted=True)
|
||||||
|
.order_by("-timestamp")[:36]
|
||||||
|
)[::-1]
|
||||||
if recent_room else []
|
if recent_room else []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal file
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-12 23:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('drama', '0002_scrollposition'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='gameevent',
|
||||||
|
name='verb',
|
||||||
|
field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn')], max_length=30),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
|
||||||
|
# Later: replace with per-actor lookup when User model gains a pronouns field.
|
||||||
|
PRONOUN_SUBJ = "yo"
|
||||||
|
PRONOUN_OBJ = "yo"
|
||||||
|
PRONOUN_POSS = "yos"
|
||||||
|
|
||||||
|
|
||||||
class GameEvent(models.Model):
|
class GameEvent(models.Model):
|
||||||
# Gate phase
|
# Gate phase
|
||||||
@@ -14,6 +20,9 @@ class GameEvent(models.Model):
|
|||||||
ROLE_SELECT_STARTED = "role_select_started"
|
ROLE_SELECT_STARTED = "role_select_started"
|
||||||
ROLE_SELECTED = "role_selected"
|
ROLE_SELECTED = "role_selected"
|
||||||
ROLES_REVEALED = "roles_revealed"
|
ROLES_REVEALED = "roles_revealed"
|
||||||
|
# Sig Select phase
|
||||||
|
SIG_READY = "sig_ready"
|
||||||
|
SIG_UNREADY = "sig_unready"
|
||||||
|
|
||||||
VERB_CHOICES = [
|
VERB_CHOICES = [
|
||||||
(ROOM_CREATED, "Room created"),
|
(ROOM_CREATED, "Room created"),
|
||||||
@@ -25,6 +34,8 @@ class GameEvent(models.Model):
|
|||||||
(ROLE_SELECT_STARTED, "Role select started"),
|
(ROLE_SELECT_STARTED, "Role select started"),
|
||||||
(ROLE_SELECTED, "Role selected"),
|
(ROLE_SELECTED, "Role selected"),
|
||||||
(ROLES_REVEALED, "Roles revealed"),
|
(ROLES_REVEALED, "Roles revealed"),
|
||||||
|
(SIG_READY, "Sig claim staked"),
|
||||||
|
(SIG_UNREADY, "Sig claim withdrawn"),
|
||||||
]
|
]
|
||||||
|
|
||||||
room = models.ForeignKey(
|
room = models.ForeignKey(
|
||||||
@@ -71,13 +82,36 @@ class GameEvent(models.Model):
|
|||||||
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
||||||
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
||||||
}
|
}
|
||||||
|
_chair_order = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
_ordinals = ["1st", "2nd", "3rd", "4th", "5th", "6th"]
|
||||||
code = d.get("role", "?")
|
code = d.get("role", "?")
|
||||||
role = d.get("role_display") or _role_names.get(code, code)
|
role = d.get("role_display") or _role_names.get(code, code)
|
||||||
return f"elects to start as the {role}, and will enjoy affinity with this Role for the remainder of the game."
|
try:
|
||||||
|
ordinal = _ordinals[_chair_order.index(code)]
|
||||||
|
except ValueError:
|
||||||
|
ordinal = "?"
|
||||||
|
return f"assumes {ordinal} Chair; yo will start the game as the {role}."
|
||||||
if self.verb == self.ROLES_REVEALED:
|
if self.verb == self.ROLES_REVEALED:
|
||||||
return "All roles assigned"
|
return "All roles assigned"
|
||||||
|
if self.verb == self.SIG_READY:
|
||||||
|
card_name = d.get("card_name", "a card")
|
||||||
|
corner_rank = d.get("corner_rank", "")
|
||||||
|
suit_icon = d.get("suit_icon", "")
|
||||||
|
if corner_rank:
|
||||||
|
icon_html = f' <i class="fa-solid {suit_icon}"></i>' if suit_icon else ""
|
||||||
|
abbrev = f" ({corner_rank}{icon_html})"
|
||||||
|
else:
|
||||||
|
abbrev = ""
|
||||||
|
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}."
|
||||||
|
if self.verb == self.SIG_UNREADY:
|
||||||
|
return f"disembodies {PRONOUN_POSS} Significator."
|
||||||
return self.verb
|
return self.verb
|
||||||
|
|
||||||
|
@property
|
||||||
|
def struck(self):
|
||||||
|
"""True when this SIG_READY event was subsequently retracted (WAIT NVM)."""
|
||||||
|
return self.data.get("retracted", False)
|
||||||
|
|
||||||
def to_activity(self, base_url):
|
def to_activity(self, base_url):
|
||||||
"""Serialise this event as an AS2 Activity dict, or None if unsupported."""
|
"""Serialise this event as an AS2 Activity dict, or None if unsupported."""
|
||||||
if not self.actor or not self.actor.username:
|
if not self.actor or not self.actor.username:
|
||||||
|
|||||||
@@ -40,6 +40,49 @@ class GameEventModelTest(TestCase):
|
|||||||
self.assertIn("actor@test.io", str(event))
|
self.assertIn("actor@test.io", str(event))
|
||||||
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||||
|
|
||||||
|
# ── to_prose — ROLE_SELECTED ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_role_selected_prose_uses_ordinal_chair(self):
|
||||||
|
for role, ordinal in [("PC", "1st"), ("NC", "2nd"), ("EC", "3rd"),
|
||||||
|
("SC", "4th"), ("AC", "5th"), ("BC", "6th")]:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||||
|
role=role, role_display="")
|
||||||
|
self.assertIn(f"assumes {ordinal} Chair", event.to_prose())
|
||||||
|
|
||||||
|
def test_role_selected_prose_includes_role_name(self):
|
||||||
|
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||||
|
role="PC", role_display="Player")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("Player", prose)
|
||||||
|
self.assertIn("yo will start the game", prose)
|
||||||
|
|
||||||
|
# ── to_prose — SIG_READY ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_prose_embodies_card_with_rank_and_icon(self):
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="Maid of Brands", corner_rank="M",
|
||||||
|
suit_icon="fa-wand-sparkles")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||||
|
self.assertIn("(M", prose)
|
||||||
|
self.assertIn("fa-wand-sparkles", prose)
|
||||||
|
|
||||||
|
def test_sig_ready_prose_omits_icon_when_none(self):
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="The Wanderer", corner_rank="0", suit_icon="")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
|
||||||
|
self.assertNotIn("fa-", prose)
|
||||||
|
|
||||||
|
def test_sig_ready_prose_degrades_without_corner_rank(self):
|
||||||
|
# Old events recorded before this change have no corner_rank key
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="Maid of Brands")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||||
|
self.assertNotIn("(", prose)
|
||||||
|
|
||||||
def test_str_without_actor_shows_system(self):
|
def test_str_without_actor_shows_system(self):
|
||||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||||
self.assertIn("system", str(event))
|
self.assertIn("system", str(event))
|
||||||
|
|||||||
@@ -78,5 +78,17 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
async def sig_reserved(self, event):
|
async def sig_reserved(self, event):
|
||||||
await self.send_json(event)
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def countdown_start(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def countdown_cancel(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def polarity_room_done(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def pick_sky_available(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)
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ var SigSelect = (function () {
|
|||||||
var overlay, deckGrid, stage, stageCard, statBlock;
|
var overlay, deckGrid, stage, stageCard, statBlock;
|
||||||
var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl;
|
var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl;
|
||||||
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
|
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
|
||||||
var reserveUrl, userRole, userPolarity;
|
var reserveUrl, readyUrl, userRole, userPolarity;
|
||||||
|
|
||||||
|
var _isReady = false;
|
||||||
|
var _takeSigBtn = null;
|
||||||
|
var _glowTimer = null;
|
||||||
|
var _glowPeak = false;
|
||||||
|
var _countdownTimer = null;
|
||||||
|
var _countdownSecondsLeft = 0;
|
||||||
|
|
||||||
var _cautionData = [];
|
var _cautionData = [];
|
||||||
var _cautionIdx = 0;
|
var _cautionIdx = 0;
|
||||||
@@ -236,6 +243,7 @@ var SigSelect = (function () {
|
|||||||
updateStage(cardEl);
|
updateStage(cardEl);
|
||||||
_stageFrozen = true;
|
_stageFrozen = true;
|
||||||
stage.classList.add('sig-stage--frozen');
|
stage.classList.add('sig-stage--frozen');
|
||||||
|
_showTakeSigBtn();
|
||||||
}
|
}
|
||||||
// Thumbs-up float for all reservations — own role sees their own indicator too
|
// Thumbs-up float for all reservations — own role sees their own indicator too
|
||||||
_placeReservedFloat(cardId, cardEl, role);
|
_placeReservedFloat(cardId, cardEl, role);
|
||||||
@@ -246,6 +254,7 @@ var SigSelect = (function () {
|
|||||||
_reservedCardId = null;
|
_reservedCardId = null;
|
||||||
_stageFrozen = false;
|
_stageFrozen = false;
|
||||||
stage.classList.remove('sig-stage--frozen');
|
stage.classList.remove('sig-stage--frozen');
|
||||||
|
_hideTakeSigBtn();
|
||||||
}
|
}
|
||||||
// Remove thumbs-up float for all releases — own role included
|
// Remove thumbs-up float for all releases — own role included
|
||||||
if (_reservedFloats[role]) {
|
if (_reservedFloats[role]) {
|
||||||
@@ -309,6 +318,232 @@ var SigSelect = (function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Reposition floats after resize ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Both reserved (thumbs-up) and hover (hand-pointer) floats are stamped with
|
||||||
|
// fixed pixel coords at placement time. When the viewport changes size the
|
||||||
|
// cards reflow but the icons stay put. Re-measure from the card's current
|
||||||
|
// bounding rect and update left/top in-place.
|
||||||
|
|
||||||
|
var _posClasses = ['--left', '--mid', '--right'];
|
||||||
|
var _xFractions = [0.15, 0.5, 0.85];
|
||||||
|
|
||||||
|
function _repositionFloats() {
|
||||||
|
if (!deckGrid) return;
|
||||||
|
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||||
|
|
||||||
|
Object.keys(_reservedFloats).forEach(function (role) {
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-reserved-by="' + role + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var idx = roles.indexOf(role);
|
||||||
|
var fc = _reservedFloats[role];
|
||||||
|
fc.style.left = (rect.left + rect.width * _xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
var posClass = _posClasses.find(function (p) {
|
||||||
|
return key.slice(-p.length) === p;
|
||||||
|
});
|
||||||
|
if (!posClass) return;
|
||||||
|
var cardId = key.slice(0, key.length - posClass.length);
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var idx = _posClasses.indexOf(posClass);
|
||||||
|
var fc = _floatingCursors[key];
|
||||||
|
fc.style.left = (rect.left + rect.width * _xFractions[idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TAKE SIG / WAIT NVM button ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function _onTakeSigClick() {
|
||||||
|
if (_isReady) {
|
||||||
|
var body = 'action=unready';
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
body += '&seconds_remaining=' + _countdownSecondsLeft;
|
||||||
|
}
|
||||||
|
fetch(readyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: body,
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
_isReady = false;
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
if (_takeSigBtn) _takeSigBtn.style.fontSize = '';
|
||||||
|
}
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = 'TAKE SIG';
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_stopCountdownGlow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fetch(readyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: 'action=ready',
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
_isReady = true;
|
||||||
|
// countdown_start WS may arrive before this response for the
|
||||||
|
// gamer who triggered the countdown — don't clobber the numeral.
|
||||||
|
if (_countdownTimer === null) {
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showTakeSigBtn() {
|
||||||
|
if (_takeSigBtn || !stage) return;
|
||||||
|
_takeSigBtn = document.createElement('button');
|
||||||
|
_takeSigBtn.id = 'id_take_sig_btn';
|
||||||
|
_takeSigBtn.className = 'btn btn-primary sig-take-sig-btn';
|
||||||
|
_takeSigBtn.type = 'button';
|
||||||
|
_takeSigBtn.textContent = 'TAKE SIG';
|
||||||
|
_takeSigBtn.addEventListener('click', _onTakeSigClick);
|
||||||
|
stage.appendChild(_takeSigBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startWaitNoGlow() {
|
||||||
|
if (_glowTimer !== null) return;
|
||||||
|
_glowPeak = false;
|
||||||
|
_glowTimer = setInterval(function () {
|
||||||
|
if (!_takeSigBtn) { _stopWaitNoGlow(); return; }
|
||||||
|
_glowPeak = !_glowPeak;
|
||||||
|
if (_glowPeak) {
|
||||||
|
_takeSigBtn.classList.add('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow =
|
||||||
|
'0 0 0.8rem 0.2rem rgba(var(--terOr), 0.75), ' +
|
||||||
|
'0 0 2rem 0.4rem rgba(var(--terOr), 0.35)';
|
||||||
|
} else {
|
||||||
|
_takeSigBtn.classList.remove('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopWaitNoGlow() {
|
||||||
|
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.classList.remove('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
_glowPeak = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startCountdownGlow() {
|
||||||
|
if (_glowTimer !== null) return;
|
||||||
|
_glowPeak = false;
|
||||||
|
_glowTimer = setInterval(function () {
|
||||||
|
if (!_takeSigBtn) { _stopCountdownGlow(); return; }
|
||||||
|
_glowPeak = !_glowPeak;
|
||||||
|
if (_glowPeak) {
|
||||||
|
_takeSigBtn.classList.add('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow =
|
||||||
|
'0 0 0.8rem 0.2rem rgba(var(--terRd), 0.75), ' +
|
||||||
|
'0 0 2rem 0.4rem rgba(var(--terRd), 0.35)';
|
||||||
|
} else {
|
||||||
|
_takeSigBtn.classList.remove('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopCountdownGlow() {
|
||||||
|
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.classList.remove('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
_glowPeak = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideTakeSigBtn() {
|
||||||
|
if (!_takeSigBtn) return;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_takeSigBtn.removeEventListener('click', _onTakeSigClick);
|
||||||
|
_takeSigBtn.remove();
|
||||||
|
_takeSigBtn = null;
|
||||||
|
_isReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polarity countdown ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _showCountdown(seconds) {
|
||||||
|
_countdownSecondsLeft = seconds;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.textContent = _countdownSecondsLeft;
|
||||||
|
_takeSigBtn.style.fontSize = '2em';
|
||||||
|
}
|
||||||
|
_startCountdownGlow();
|
||||||
|
if (_countdownTimer !== null) clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = setInterval(function () {
|
||||||
|
_countdownSecondsLeft -= 1;
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = _countdownSecondsLeft;
|
||||||
|
if (_countdownSecondsLeft <= 0) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
_stopCountdownGlow(); // server drives the transition via Celery task
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideCountdown() {
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
}
|
||||||
|
_stopCountdownGlow();
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.style.fontSize = '';
|
||||||
|
if (_isReady) {
|
||||||
|
// Countdown cancelled by another gamer — restore WAIT NVM state
|
||||||
|
_takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlay dismiss + waiting message ─────────────────────────────────
|
||||||
|
|
||||||
|
function _dismissSigOverlay() {
|
||||||
|
_hideCountdown();
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
var backdrop = document.querySelector('.sig-backdrop');
|
||||||
|
if (backdrop) backdrop.remove();
|
||||||
|
if (overlay) { overlay.remove(); overlay = null; }
|
||||||
|
// Remove all floating cursors (hover + thumbs-up) from the portal
|
||||||
|
Object.keys(_reservedFloats).forEach(function (role) {
|
||||||
|
_reservedFloats[role].remove();
|
||||||
|
});
|
||||||
|
_reservedFloats = {};
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
_floatingCursors[key].remove();
|
||||||
|
});
|
||||||
|
_floatingCursors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showWaitingMsg(pendingPolarity) {
|
||||||
|
if (document.getElementById('id_hex_waiting_msg')) return;
|
||||||
|
var msg = document.createElement('p');
|
||||||
|
msg.id = 'id_hex_waiting_msg';
|
||||||
|
msg.textContent = pendingPolarity === 'gravity'
|
||||||
|
? 'Gravity settling . . .'
|
||||||
|
: 'Levity appraising . . .';
|
||||||
|
var center = document.querySelector('.table-center');
|
||||||
|
if (center) center.appendChild(msg);
|
||||||
|
}
|
||||||
|
|
||||||
// ── WS events ─────────────────────────────────────────────────────────
|
// ── WS events ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
window.addEventListener('room:sig_reserved', function (e) {
|
window.addEventListener('room:sig_reserved', function (e) {
|
||||||
@@ -321,6 +556,33 @@ var SigSelect = (function () {
|
|||||||
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:countdown_start', function (e) {
|
||||||
|
if (!overlay) return;
|
||||||
|
_showCountdown(e.detail.seconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:countdown_cancel', function (e) {
|
||||||
|
_hideCountdown();
|
||||||
|
_countdownSecondsLeft = e.detail.seconds_remaining;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:polarity_room_done', function (e) {
|
||||||
|
if (!overlay) return;
|
||||||
|
if (e.detail.polarity !== userPolarity) return;
|
||||||
|
var pendingPolarity = userPolarity === 'levity' ? 'gravity' : 'levity';
|
||||||
|
_dismissSigOverlay();
|
||||||
|
_showWaitingMsg(pendingPolarity);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:pick_sky_available', function () {
|
||||||
|
var msg = document.getElementById('id_hex_waiting_msg');
|
||||||
|
if (msg) msg.remove();
|
||||||
|
var btn = document.getElementById('id_pick_sky_btn');
|
||||||
|
if (btn) btn.style.display = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize:end', _repositionFloats);
|
||||||
|
|
||||||
// ── WS send ───────────────────────────────────────────────────────────
|
// ── WS send ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sendHover(cardId, active) {
|
function sendHover(cardId, active) {
|
||||||
@@ -376,9 +638,19 @@ var SigSelect = (function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
reserveUrl = overlay.dataset.reserveUrl;
|
reserveUrl = overlay.dataset.reserveUrl;
|
||||||
|
readyUrl = overlay.dataset.readyUrl;
|
||||||
|
|
||||||
userRole = overlay.dataset.userRole;
|
userRole = overlay.dataset.userRole;
|
||||||
userPolarity= overlay.dataset.polarity;
|
userPolarity= overlay.dataset.polarity;
|
||||||
|
|
||||||
|
// PICK SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available
|
||||||
|
var pickSkyBtn = document.getElementById('id_pick_sky_btn');
|
||||||
|
if (pickSkyBtn) {
|
||||||
|
pickSkyBtn.addEventListener('click', function () {
|
||||||
|
if (typeof Tray !== 'undefined') Tray.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Restore reservations from server-rendered JSON (page-load state).
|
// Restore reservations from server-rendered JSON (page-load state).
|
||||||
// Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered
|
// Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered
|
||||||
// in room.js before this script) has already applied paddingBottom and
|
// in room.js before this script) has already applied paddingBottom and
|
||||||
@@ -390,6 +662,12 @@ var SigSelect = (function () {
|
|||||||
Object.keys(existing).forEach(function (cardId) {
|
Object.keys(existing).forEach(function (cardId) {
|
||||||
applyReservation(cardId, existing[cardId], true);
|
applyReservation(cardId, existing[cardId], true);
|
||||||
});
|
});
|
||||||
|
// Restore WAIT NVM state if gamer was already ready before page load
|
||||||
|
if (overlay.dataset.ready === 'true' && _takeSigBtn) {
|
||||||
|
_isReady = true;
|
||||||
|
_takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (document.readyState === 'complete') {
|
if (document.readyState === 'complete') {
|
||||||
_replayReservations();
|
_replayReservations();
|
||||||
@@ -469,6 +747,11 @@ var SigSelect = (function () {
|
|||||||
Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); });
|
Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); });
|
||||||
_reservedFloats = {};
|
_reservedFloats = {};
|
||||||
_cursorPortal = null;
|
_cursorPortal = null;
|
||||||
|
_isReady = false;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
_hideCountdown();
|
||||||
|
_countdownSecondsLeft = 0;
|
||||||
init();
|
init();
|
||||||
},
|
},
|
||||||
_setFrozen: function (v) { _stageFrozen = v; },
|
_setFrozen: function (v) { _stageFrozen = v; },
|
||||||
|
|||||||
95
src/apps/epic/tasks.py
Normal file
95
src/apps/epic/tasks.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Countdown scheduler for the polarity-room TAKE SIG gate.
|
||||||
|
|
||||||
|
Uses threading.Timer so no separate Celery worker is needed in development.
|
||||||
|
Single-process only — swap for a Celery task if production uses multiple
|
||||||
|
web workers (gunicorn -w N with N > 1).
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||||
|
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||||
|
|
||||||
|
# In-process registry of pending timers: "{room_id}_{polarity}" → Timer
|
||||||
|
_timers = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(room_id, polarity):
|
||||||
|
return f'sig_countdown_{room_id}_{polarity}'
|
||||||
|
|
||||||
|
|
||||||
|
def _group_send(room_id, msg):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(f'room_{room_id}', msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _fire(room_id, polarity, token):
|
||||||
|
"""Callback run by threading.Timer after the countdown expires."""
|
||||||
|
# Token guard: if cancelled or superseded, cache entry will differ
|
||||||
|
if cache.get(_cache_key(room_id, polarity)) != token:
|
||||||
|
return
|
||||||
|
|
||||||
|
from apps.epic.models import Room, SigReservation
|
||||||
|
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return
|
||||||
|
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
|
||||||
|
# Idempotency: seats already assigned
|
||||||
|
if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Safety: all three must still be ready
|
||||||
|
ready_reservations = list(
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
||||||
|
.select_related('seat', 'card')
|
||||||
|
)
|
||||||
|
if len(ready_reservations) < 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
for res in ready_reservations:
|
||||||
|
if res.seat:
|
||||||
|
res.seat.significator = res.card
|
||||||
|
res.seat.save(update_fields=['significator'])
|
||||||
|
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)
|
||||||
|
|
||||||
|
_group_send(room_id, {'type': 'polarity_room_done', 'polarity': polarity})
|
||||||
|
|
||||||
|
if not room.table_seats.filter(significator__isnull=True).exists():
|
||||||
|
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
|
||||||
|
_group_send(room_id, {'type': 'pick_sky_available'})
|
||||||
|
|
||||||
|
cache.delete(_cache_key(room_id, polarity))
|
||||||
|
_timers.pop(f'{room_id}_{polarity}', None)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_polarity_confirm(room_id, polarity, seconds):
|
||||||
|
"""Schedule a polarity confirm `seconds` seconds from now. Cancels any prior timer."""
|
||||||
|
cancel_polarity_confirm(room_id, polarity)
|
||||||
|
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
cache.set(_cache_key(room_id, polarity), token, timeout=int(seconds) + 60)
|
||||||
|
|
||||||
|
timer = threading.Timer(seconds, _fire, args=[str(room_id), polarity, token])
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
_timers[f'{room_id}_{polarity}'] = timer
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_polarity_confirm(room_id, polarity):
|
||||||
|
"""Cancel any pending confirm for this room + polarity."""
|
||||||
|
timer = _timers.pop(f'{room_id}_{polarity}', None)
|
||||||
|
if timer:
|
||||||
|
timer.cancel()
|
||||||
|
cache.delete(_cache_key(room_id, polarity))
|
||||||
@@ -1605,8 +1605,10 @@ class PickSkyRenderingTest(TestCase):
|
|||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertContains(response, "tray-sig-card")
|
self.assertContains(response, "tray-sig-card")
|
||||||
|
|
||||||
def test_pick_sky_btn_absent_during_sig_select(self):
|
def test_pick_sky_btn_hidden_during_sig_select(self):
|
||||||
|
# Rendered hidden (display:none) so JS can reveal it on pick_sky_available WS event
|
||||||
self.room.table_status = Room.SIG_SELECT
|
self.room.table_status = Room.SIG_SELECT
|
||||||
self.room.save()
|
self.room.save()
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertNotContains(response, "id_pick_sky_btn")
|
self.assertContains(response, 'id="id_pick_sky_btn"')
|
||||||
|
self.assertContains(response, 'style="display:none"')
|
||||||
|
|||||||
@@ -301,10 +301,24 @@ def _role_select_context(room, user):
|
|||||||
elif user_role in _GRAVITY_ROLES:
|
elif user_role in _GRAVITY_ROLES:
|
||||||
user_polarity = 'gravity'
|
user_polarity = 'gravity'
|
||||||
|
|
||||||
|
user_reservation = SigReservation.objects.filter(
|
||||||
|
room=room, gamer=user
|
||||||
|
).first() if user.is_authenticated else None
|
||||||
ctx["user_seat"] = user_seat
|
ctx["user_seat"] = user_seat
|
||||||
ctx["user_polarity"] = user_polarity
|
ctx["user_polarity"] = user_polarity
|
||||||
|
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
|
||||||
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
||||||
|
|
||||||
|
# Has this gamer's polarity already had significators assigned?
|
||||||
|
# (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
|
||||||
|
if user_polarity:
|
||||||
|
_polarity_roles = _LEVITY_ROLES if user_polarity == 'levity' else _GRAVITY_ROLES
|
||||||
|
ctx["polarity_done"] = not room.table_seats.filter(
|
||||||
|
role__in=_polarity_roles, significator__isnull=True
|
||||||
|
).exists()
|
||||||
|
else:
|
||||||
|
ctx["polarity_done"] = False
|
||||||
|
|
||||||
# Pre-load existing reservations for this polarity so JS can restore
|
# Pre-load existing reservations for this polarity so JS can restore
|
||||||
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
|
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
|
||||||
if user_polarity:
|
if user_polarity:
|
||||||
@@ -643,6 +657,21 @@ def sig_reserve(request, room_id):
|
|||||||
if action == "release":
|
if action == "release":
|
||||||
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
released_card_id = existing.card_id if existing else None
|
released_card_id = existing.card_id if existing else None
|
||||||
|
if existing and existing.ready:
|
||||||
|
# Gamer released while ready — treat as an implicit WAIT NVM
|
||||||
|
prior = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_READY
|
||||||
|
).last()
|
||||||
|
if prior and not prior.data.get("retracted"):
|
||||||
|
prior.data["retracted"] = True
|
||||||
|
prior.save(update_fields=["data"])
|
||||||
|
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||||
|
polarity = existing.polarity
|
||||||
|
all_ready = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity, ready=True
|
||||||
|
).count() == 3
|
||||||
|
if all_ready:
|
||||||
|
_notify_countdown_cancel(room_id, polarity, seconds_remaining=12)
|
||||||
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
||||||
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
|
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
@@ -699,8 +728,31 @@ def sig_ready(request, room_id):
|
|||||||
if action == "ready":
|
if action == "ready":
|
||||||
if reservation is None:
|
if reservation is None:
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
if reservation.ready:
|
||||||
|
return HttpResponse(status=200) # idempotent — already ready, don't re-trigger countdown
|
||||||
reservation.ready = True
|
reservation.ready = True
|
||||||
reservation.save(update_fields=["ready"])
|
reservation.save(update_fields=["ready"])
|
||||||
|
card = reservation.card
|
||||||
|
if card and card.arcana == TarotCard.MIDDLE:
|
||||||
|
_pol_prefix = "Leavened" if reservation.polarity == SigReservation.LEVITY else "Graven"
|
||||||
|
_card_display = f"{_pol_prefix} {card.name_title}"
|
||||||
|
elif card and card.arcana == TarotCard.MAJOR:
|
||||||
|
_base = card.name_title.removeprefix("The ")
|
||||||
|
_pol_suffix = "of Light" if reservation.polarity == SigReservation.LEVITY else "from the Grave"
|
||||||
|
_card_display = f"{_base} {_pol_suffix}"
|
||||||
|
else:
|
||||||
|
_card_display = card.name_title if card else "a card"
|
||||||
|
record(room, GameEvent.SIG_READY, actor=request.user,
|
||||||
|
card_name=_card_display,
|
||||||
|
corner_rank=card.corner_rank if card else "",
|
||||||
|
suit_icon=card.suit_icon if card else "")
|
||||||
|
# Retract the most recent un-retracted SIG_UNREADY (cancellation is now moot)
|
||||||
|
prior_unready = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_UNREADY
|
||||||
|
).last()
|
||||||
|
if prior_unready and not prior_unready.data.get("retracted"):
|
||||||
|
prior_unready.data["retracted"] = True
|
||||||
|
prior_unready.save(update_fields=["data"])
|
||||||
|
|
||||||
# Check if all three in this polarity are now ready
|
# Check if all three in this polarity are now ready
|
||||||
polarity = reservation.polarity
|
polarity = reservation.polarity
|
||||||
@@ -709,6 +761,7 @@ def sig_ready(request, room_id):
|
|||||||
room=room, polarity=polarity, ready=True
|
room=room, polarity=polarity, ready=True
|
||||||
).count()
|
).count()
|
||||||
if ready_count == 3:
|
if ready_count == 3:
|
||||||
|
from apps.epic.tasks import schedule_polarity_confirm
|
||||||
# Use saved countdown_remaining if a pause was recorded, else 12
|
# Use saved countdown_remaining if a pause was recorded, else 12
|
||||||
saved = SigReservation.objects.filter(
|
saved = SigReservation.objects.filter(
|
||||||
room=room, polarity=polarity
|
room=room, polarity=polarity
|
||||||
@@ -716,12 +769,21 @@ def sig_ready(request, room_id):
|
|||||||
"countdown_remaining", flat=True
|
"countdown_remaining", flat=True
|
||||||
).first()
|
).first()
|
||||||
seconds = saved if saved is not None else 12
|
seconds = saved if saved is not None else 12
|
||||||
|
schedule_polarity_confirm(str(room_id), polarity, seconds)
|
||||||
_notify_countdown_start(room_id, polarity, seconds=seconds)
|
_notify_countdown_start(room_id, polarity, seconds=seconds)
|
||||||
|
|
||||||
else: # unready
|
else: # unready
|
||||||
if reservation is not None:
|
if reservation is not None:
|
||||||
reservation.ready = False
|
reservation.ready = False
|
||||||
reservation.save(update_fields=["ready"])
|
reservation.save(update_fields=["ready"])
|
||||||
|
# Mark the most recent un-retracted SIG_READY event for this actor
|
||||||
|
prior = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_READY
|
||||||
|
).last()
|
||||||
|
if prior and not prior.data.get("retracted"):
|
||||||
|
prior.data["retracted"] = True
|
||||||
|
prior.save(update_fields=["data"])
|
||||||
|
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||||
polarity = reservation.polarity
|
polarity = reservation.polarity
|
||||||
|
|
||||||
# Save remaining seconds on all polarity reservations
|
# Save remaining seconds on all polarity reservations
|
||||||
@@ -733,61 +795,15 @@ def sig_ready(request, room_id):
|
|||||||
countdown_remaining=seconds_remaining
|
countdown_remaining=seconds_remaining
|
||||||
)
|
)
|
||||||
_notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
|
_notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
|
||||||
|
from apps.epic.tasks import cancel_polarity_confirm
|
||||||
|
cancel_polarity_confirm(str(room_id), polarity)
|
||||||
|
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def sig_confirm(request, room_id):
|
def sig_confirm(request, room_id):
|
||||||
"""Client posts this when the polarity-room countdown reaches zero.
|
"""No-op: polarity confirmation is now driven server-side by threading.Timer in tasks.py."""
|
||||||
POST body: polarity=levity|gravity
|
|
||||||
Sets significators on the three seats and broadcasts polarity_room_done.
|
|
||||||
When both polarities are confirmed, broadcasts pick_sky_available and
|
|
||||||
transitions the room to SKY_SELECT.
|
|
||||||
"""
|
|
||||||
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 = _canonical_user_seat(room, request.user)
|
|
||||||
if user_seat is None:
|
|
||||||
return HttpResponse(status=403)
|
|
||||||
|
|
||||||
seat_polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
|
|
||||||
polarity = request.POST.get("polarity", seat_polarity)
|
|
||||||
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
|
||||||
|
|
||||||
# Idempotency: if all seats in this polarity already have significators, skip
|
|
||||||
already_done = not room.table_seats.filter(
|
|
||||||
role__in=polarity_roles, significator__isnull=True
|
|
||||||
).exists()
|
|
||||||
if already_done:
|
|
||||||
return HttpResponse(status=200)
|
|
||||||
|
|
||||||
# Guard: all three must be ready
|
|
||||||
ready_reservations = list(
|
|
||||||
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
|
||||||
.select_related("seat", "card")
|
|
||||||
)
|
|
||||||
if len(ready_reservations) < 3:
|
|
||||||
return HttpResponse(status=400)
|
|
||||||
|
|
||||||
# Set significators from reservations
|
|
||||||
for res in ready_reservations:
|
|
||||||
if res.seat:
|
|
||||||
res.seat.significator = res.card
|
|
||||||
res.seat.save(update_fields=["significator"])
|
|
||||||
|
|
||||||
_notify_polarity_room_done(room_id, polarity)
|
|
||||||
|
|
||||||
# Check if both polarities are now confirmed
|
|
||||||
all_done = not room.table_seats.filter(significator__isnull=True).exists()
|
|
||||||
if all_done:
|
|
||||||
room.table_status = Room.SKY_SELECT
|
|
||||||
room.save(update_fields=["table_status"])
|
|
||||||
_notify_pick_sky_available(room_id)
|
|
||||||
|
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class BillboardScrollTest(FunctionalTest):
|
|||||||
self.assertIn("deposits a Free Token for slot 2 (expires in 7 days).", scroll.text)
|
self.assertIn("deposits a Free Token for slot 2 (expires in 7 days).", scroll.text)
|
||||||
|
|
||||||
# Role selection event is rendered as prose
|
# Role selection event is rendered as prose
|
||||||
self.assertIn("elects to start as the Player", scroll.text)
|
self.assertIn("assumes 1st Chair", scroll.text)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 3 — current user's events are right-aligned; others' are left #
|
# Test 3 — current user's events are right-aligned; others' are left #
|
||||||
@@ -354,3 +354,149 @@ class BillscrollEntryLayoutTest(FunctionalTest):
|
|||||||
# events[0] is the backdated record (oldest first, ascending order)
|
# events[0] is the backdated record (oldest first, ascending order)
|
||||||
old_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
old_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
||||||
self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}")
|
self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}")
|
||||||
|
|
||||||
|
|
||||||
|
class BillscrollGearMenuTest(FunctionalTest):
|
||||||
|
"""
|
||||||
|
FT: the billscroll page has a gear menu that filters events by label.
|
||||||
|
|
||||||
|
Frame = all regular (non-struck) drama entries.
|
||||||
|
Redact = struck-through (retracted) entries, e.g. a WAIT NVM after TAKE SIG.
|
||||||
|
|
||||||
|
Scenario (one gamer, Role + Sig events):
|
||||||
|
1. Both labels checked by default — all events visible.
|
||||||
|
2. Uncheck Redact → OK: struck entries disappear.
|
||||||
|
3. Recheck Redact + uncheck Frame → OK: regular entries gone; struck
|
||||||
|
entries visible (but still render struck-through — they remain "redacted"
|
||||||
|
in the narrative sense).
|
||||||
|
4. Recheck Frame → OK: all entries return.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.founder = User.objects.create(email="founder@geartest.io")
|
||||||
|
self.room = Room.objects.create(name="Gear Filter Room", owner=self.founder)
|
||||||
|
# Two Frame events — ROLE_SELECTED, non-struck
|
||||||
|
record(self.room, GameEvent.ROLE_SELECTED, actor=self.founder,
|
||||||
|
role="PC", slot_number=1, role_display="Player")
|
||||||
|
record(self.room, GameEvent.ROLE_SELECTED, actor=self.founder,
|
||||||
|
role="NC", slot_number=2, role_display="Narrator")
|
||||||
|
# Two Redact events — SIG_READY with retracted=True → event.struck is True
|
||||||
|
sig1 = record(self.room, GameEvent.SIG_READY, actor=self.founder,
|
||||||
|
card_name="The Wanderer", corner_rank="0", suit_icon="")
|
||||||
|
sig1.data["retracted"] = True
|
||||||
|
sig1.save(update_fields=["data"])
|
||||||
|
sig2 = record(self.room, GameEvent.SIG_READY, actor=self.founder,
|
||||||
|
card_name="Maid of Brands", corner_rank="M",
|
||||||
|
suit_icon="fa-wand-sparkles")
|
||||||
|
sig2.data["retracted"] = True
|
||||||
|
sig2.save(update_fields=["data"])
|
||||||
|
|
||||||
|
def _go_to_scroll(self):
|
||||||
|
self.create_pre_authenticated_session("founder@geartest.io")
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||||
|
)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_gear(self):
|
||||||
|
"""Click the gear btn and return the now-visible menu element."""
|
||||||
|
gear = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR,
|
||||||
|
".gear-btn[data-menu-target='id_billscroll_menu']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.browser.execute_script("arguments[0].click()", gear)
|
||||||
|
return self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_billscroll_menu")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _visible_events(self, label):
|
||||||
|
"""Count displayed .drama-event elements with the given data-label."""
|
||||||
|
els = self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, f".drama-event[data-label='{label}']"
|
||||||
|
)
|
||||||
|
return sum(1 for e in els if e.is_displayed())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Step 1 — gear menu opens; both labels present and pre-checked #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_gear_menu_shows_frame_and_redact_checkboxes(self):
|
||||||
|
self._go_to_scroll()
|
||||||
|
menu = self._open_gear()
|
||||||
|
self.assertTrue(menu.is_displayed())
|
||||||
|
frame_cb = menu.find_element(By.CSS_SELECTOR, "input[value='frame']")
|
||||||
|
redact_cb = menu.find_element(By.CSS_SELECTOR, "input[value='redact']")
|
||||||
|
self.assertTrue(frame_cb.is_selected())
|
||||||
|
self.assertTrue(redact_cb.is_selected())
|
||||||
|
self.assertIn("Frame", menu.text)
|
||||||
|
self.assertIn("Redact", menu.text)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Steps 2 – 4 — filter flow #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_gear_menu_filter_flow(self):
|
||||||
|
self._go_to_scroll()
|
||||||
|
|
||||||
|
# Step 1: all 4 events visible (2 frame + 2 redact)
|
||||||
|
self.assertEqual(self._visible_events("frame"), 2)
|
||||||
|
self.assertEqual(self._visible_events("redact"), 2)
|
||||||
|
|
||||||
|
# Step 2: uncheck Redact → OK → struck entries disappear
|
||||||
|
menu = self._open_gear()
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click()
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0))
|
||||||
|
self.assertEqual(self._visible_events("frame"), 2)
|
||||||
|
|
||||||
|
# Step 3: recheck Redact + uncheck Frame → OK
|
||||||
|
# Redact events re-appear (still struck-through); Frame events gone.
|
||||||
|
menu = self._open_gear()
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click() # recheck
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "input[value='frame']").click() # uncheck
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(self._visible_events("frame"), 0))
|
||||||
|
self.assertEqual(self._visible_events("redact"), 2)
|
||||||
|
# Struck-through entries still carry the .struck class (visually "gone" in narrative)
|
||||||
|
redact_bodies = self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".drama-event[data-label='redact'] .drama-event-body"
|
||||||
|
)
|
||||||
|
self.assertTrue(all("struck" in b.get_attribute("class") for b in redact_bodies))
|
||||||
|
|
||||||
|
# Step 4: recheck Frame → OK → all events return
|
||||||
|
menu = self._open_gear()
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "input[value='frame']").click() # recheck
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(self._visible_events("frame"), 2))
|
||||||
|
self.assertEqual(self._visible_events("redact"), 2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Persistence — filter survives a full page reload #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_filter_selection_persists_across_refresh(self):
|
||||||
|
self._go_to_scroll()
|
||||||
|
|
||||||
|
# Uncheck Redact → OK: struck entries disappear
|
||||||
|
menu = self._open_gear()
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "input[value='redact']").click()
|
||||||
|
menu.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
|
||||||
|
self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0))
|
||||||
|
|
||||||
|
# Hard reload — same URL, same session cookie
|
||||||
|
self.browser.refresh()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_drama_scroll"))
|
||||||
|
|
||||||
|
# Struck entries still absent after reload
|
||||||
|
self.wait_for(lambda: self.assertEqual(self._visible_events("redact"), 0))
|
||||||
|
self.assertEqual(self._visible_events("frame"), 2)
|
||||||
|
|
||||||
|
# Gear menu still shows Redact unchecked
|
||||||
|
menu = self._open_gear()
|
||||||
|
redact_cb = menu.find_element(By.CSS_SELECTOR, "input[value='redact']")
|
||||||
|
self.assertFalse(redact_cb.is_selected())
|
||||||
|
|||||||
@@ -372,15 +372,15 @@ class SigSelectThemeTest(FunctionalTest):
|
|||||||
self.assertEqual(corr.text, "")
|
self.assertEqual(corr.text, "")
|
||||||
|
|
||||||
|
|
||||||
# ── TAKE SIG / WAIT NO — ready gate ──────────────────────────────────────────
|
# ── TAKE SIG / WAIT NVM — ready gate ──────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# TAKE SIG (.btn.btn-primary) appears at the bottom-left corner of the card
|
# TAKE SIG (.btn.btn-primary) appears at the bottom-left corner of the card
|
||||||
# stage preview once a gamer has clicked OK on a card (SigReservation exists).
|
# stage preview once a gamer has clicked OK on a card (SigReservation exists).
|
||||||
# Clicking it sets the gamer's status to ready and changes the btn to WAIT NO.
|
# Clicking it sets the gamer's status to ready and changes the btn to WAIT NVM.
|
||||||
# WAIT NO cancels the ready status and reverts back to TAKE SIG.
|
# WAIT NVM cancels the ready status and reverts back to TAKE SIG.
|
||||||
#
|
#
|
||||||
# When all three gamers in a polarity WS room are ready, a 12-second countdown
|
# When all three gamers in a polarity WS room are ready, a 12-second countdown
|
||||||
# starts. Any WAIT NO during the countdown cancels it; the saved remaining time
|
# starts. Any WAIT NVM during the countdown cancels it; the saved remaining time
|
||||||
# is resumed when all three are ready again. When the countdown completes
|
# is resumed when all three are ready again. When the countdown completes
|
||||||
# (client POSTs sig_confirm) the polarity group returns to the table hex.
|
# (client POSTs sig_confirm) the polarity group returns to the table hex.
|
||||||
# When both polarity groups have confirmed, PICK SKY btn appears in the hex
|
# When both polarity groups have confirmed, PICK SKY btn appears in the hex
|
||||||
@@ -390,7 +390,7 @@ class SigSelectThemeTest(FunctionalTest):
|
|||||||
|
|
||||||
|
|
||||||
class SigReadyGateTest(FunctionalTest):
|
class SigReadyGateTest(FunctionalTest):
|
||||||
"""Single-browser tests for TAKE SIG / WAIT NO btn."""
|
"""Single-browser tests for TAKE SIG / WAIT NVM btn."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@@ -450,7 +450,7 @@ class SigReadyGateTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
self.assertIn("TAKE SIG", take_sig_btn.text.upper())
|
self.assertIn("TAKE SIG", take_sig_btn.text.upper())
|
||||||
|
|
||||||
# ── SRG3: TAKE SIG → WAIT NO ─────────────────────────────────────── #
|
# ── SRG3: TAKE SIG → WAIT NVM ─────────────────────────────────────── #
|
||||||
|
|
||||||
def test_take_sig_btn_becomes_wait_no_after_click(self):
|
def test_take_sig_btn_becomes_wait_no_after_click(self):
|
||||||
room = self._setup_sig_room()
|
room = self._setup_sig_room()
|
||||||
@@ -467,9 +467,16 @@ class SigReadyGateTest(FunctionalTest):
|
|||||||
wait_no_btn = self.wait_for(
|
wait_no_btn = self.wait_for(
|
||||||
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
)
|
)
|
||||||
self.assertIn("WAIT NO", wait_no_btn.text.upper())
|
self.assertIn("WAIT NVM", wait_no_btn.text.upper())
|
||||||
|
|
||||||
# ── SRG4: WAIT NO → TAKE SIG ─────────────────────────────────────── #
|
# WAIT NVM pulses a --terOr glow: btn-cancel class appears within one tick
|
||||||
|
self.wait_for(
|
||||||
|
lambda: "btn-cancel" in self.browser.find_element(
|
||||||
|
By.ID, "id_take_sig_btn"
|
||||||
|
).get_attribute("class")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── SRG4: WAIT NVM → TAKE SIG ─────────────────────────────────────── #
|
||||||
|
|
||||||
def test_wait_no_reverts_to_take_sig(self):
|
def test_wait_no_reverts_to_take_sig(self):
|
||||||
room = self._setup_sig_room()
|
room = self._setup_sig_room()
|
||||||
@@ -481,8 +488,8 @@ class SigReadyGateTest(FunctionalTest):
|
|||||||
btn = self.wait_for(
|
btn = self.wait_for(
|
||||||
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
lambda: self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
)
|
)
|
||||||
btn.click() # → WAIT NO
|
btn.click() # → WAIT NVM
|
||||||
self.wait_for(lambda: "WAIT NO" in self.browser.find_element(
|
self.wait_for(lambda: "WAIT NVM" in self.browser.find_element(
|
||||||
By.ID, "id_take_sig_btn").text.upper()
|
By.ID, "id_take_sig_btn").text.upper()
|
||||||
)
|
)
|
||||||
btn = self.browser.find_element(By.ID, "id_take_sig_btn")
|
btn = self.browser.find_element(By.ID, "id_take_sig_btn")
|
||||||
@@ -562,20 +569,21 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
|
|||||||
)
|
)
|
||||||
b.find_element(By.ID, "id_take_sig_btn").click()
|
b.find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
# All three browsers should now see the countdown
|
# All three browsers should now see the countdown button (numeral text)
|
||||||
for b in browsers:
|
for b in browsers:
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b
|
lambda: b.find_element(By.ID, "id_take_sig_btn").text.isdigit(),
|
||||||
|
browser=b,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
for b in browsers:
|
for b in browsers:
|
||||||
b.quit()
|
b.quit()
|
||||||
|
|
||||||
# ── SRG6: countdown disappears when WAIT NO clicked ──────────────── #
|
# ── SRG6: countdown disappears when WAIT NVM clicked ──────────────── #
|
||||||
|
|
||||||
@tag("channels")
|
@tag("channels")
|
||||||
def test_countdown_disappears_when_any_levity_gamer_clicks_wait_no(self):
|
def test_countdown_disappears_when_any_levity_gamer_clicks_wait_no(self):
|
||||||
"""Any WAIT NO during the countdown cancels it for all three browsers."""
|
"""Any WAIT NVM during the countdown cancels it for all three browsers."""
|
||||||
room, emails = self._setup_sig_select_room()
|
room, emails = self._setup_sig_select_room()
|
||||||
levity_emails = [emails[0], emails[1], emails[3]]
|
levity_emails = [emails[0], emails[1], emails[3]]
|
||||||
browsers = []
|
browsers = []
|
||||||
@@ -600,19 +608,20 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
|
|||||||
)
|
)
|
||||||
b.find_element(By.ID, "id_take_sig_btn").click()
|
b.find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
# Confirm countdown started for all
|
# Confirm countdown started for all (button text is a numeral)
|
||||||
for b in browsers:
|
for b in browsers:
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b
|
lambda: b.find_element(By.ID, "id_take_sig_btn").text.isdigit(),
|
||||||
|
browser=b,
|
||||||
)
|
)
|
||||||
|
|
||||||
# PC clicks WAIT NO
|
# PC clicks the countdown button to cancel
|
||||||
browsers[0].find_element(By.ID, "id_take_sig_btn").click()
|
browsers[0].find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
# Countdown element should disappear for all three
|
# Countdown should cancel for all three (button back to WAIT NVM)
|
||||||
for b in browsers:
|
for b in browsers:
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: len(b.find_elements(By.ID, "id_sig_countdown")) == 0,
|
lambda: b.find_element(By.ID, "id_take_sig_btn").text == "WAIT NVM",
|
||||||
browser=b,
|
browser=b,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -666,8 +675,9 @@ class SigReadyCountdownChannelsTest(ChannelsFunctionalTest):
|
|||||||
b.find_element(By.ID, "id_take_sig_btn").click()
|
b.find_element(By.ID, "id_take_sig_btn").click()
|
||||||
|
|
||||||
# Wait for countdown to expire or be confirmed; PICK SKY appears in hex
|
# Wait for countdown to expire or be confirmed; PICK SKY appears in hex
|
||||||
|
# countdown is 12 s so use wait_for_slow (MAX_WAIT=10 is not enough)
|
||||||
for b in browsers:
|
for b in browsers:
|
||||||
self.wait_for(
|
self.wait_for_slow(
|
||||||
lambda: b.find_element(By.ID, "id_pick_sky_btn"), browser=b
|
lambda: b.find_element(By.ID, "id_pick_sky_btn"), browser=b
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ describe("SigSelect", () => {
|
|||||||
data-polarity="${polarity}"
|
data-polarity="${polarity}"
|
||||||
data-user-role="${userRole}"
|
data-user-role="${userRole}"
|
||||||
data-reserve-url="/epic/room/test/sig-reserve"
|
data-reserve-url="/epic/room/test/sig-reserve"
|
||||||
|
data-ready-url="/epic/room/test/sig-ready"
|
||||||
|
|
||||||
data-reservations="${reservations.replace(/"/g, '"')}">
|
data-reservations="${reservations.replace(/"/g, '"')}">
|
||||||
<div class="sig-modal">
|
<div class="sig-modal">
|
||||||
<div class="sig-stage">
|
<div class="sig-stage">
|
||||||
@@ -605,4 +607,79 @@ describe("SigSelect", () => {
|
|||||||
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── WAIT NVM glow pulse ────────────────────────────────────────────────────── //
|
||||||
|
//
|
||||||
|
// After clicking TAKE SIG (POST ok → isReady=true) a setInterval pulses the
|
||||||
|
// button at 600ms: odd ticks add .btn-cancel + a --terOr outer box-shadow;
|
||||||
|
// even ticks remove both. Uses jasmine.clock() to advance the fake timer.
|
||||||
|
|
||||||
|
describe("WAIT NVM glow pulse", () => {
|
||||||
|
let takeSigBtn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jasmine.clock().install();
|
||||||
|
// Pre-reserve card 42 as PC so _showTakeSigBtn() fires during init
|
||||||
|
makeFixture({ reservations: '{"42":"PC"}' });
|
||||||
|
takeSigBtn = document.getElementById("id_take_sig_btn");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jasmine.clock().uninstall();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function clickTakeSig() {
|
||||||
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
// Flush the fetch .then() so _startWaitNoGlow() is called
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("adds .btn-cancel after the first pulse tick (600 ms)", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601);
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets a non-empty box-shadow after the first pulse tick", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601);
|
||||||
|
expect(takeSigBtn.style.boxShadow).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .btn-cancel on the second tick (even / trough)", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601); // peak
|
||||||
|
jasmine.clock().tick(600); // trough
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears box-shadow on the trough tick", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601);
|
||||||
|
jasmine.clock().tick(600);
|
||||||
|
expect(takeSigBtn.style.boxShadow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601); // glow is on
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
|
||||||
|
|
||||||
|
// Click again → WAIT NVM → fetch unready → _stopWaitNoGlow()
|
||||||
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
||||||
|
expect(takeSigBtn.style.boxShadow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("glow does not advance after being stopped", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601); // peak
|
||||||
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve(); // stop
|
||||||
|
jasmine.clock().tick(600); // would be another tick if running
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,13 +83,15 @@
|
|||||||
#id_wallet_applet_menu { @extend %applet-menu; }
|
#id_wallet_applet_menu { @extend %applet-menu; }
|
||||||
#id_room_menu { @extend %applet-menu; }
|
#id_room_menu { @extend %applet-menu; }
|
||||||
#id_billboard_applet_menu { @extend %applet-menu; }
|
#id_billboard_applet_menu { @extend %applet-menu; }
|
||||||
|
#id_billscroll_menu { @extend %applet-menu; }
|
||||||
|
|
||||||
// Page-level gear buttons — fixed to viewport bottom-right
|
// Page-level gear buttons — fixed to viewport bottom-right
|
||||||
.gameboard-page,
|
.gameboard-page,
|
||||||
.dashboard-page,
|
.dashboard-page,
|
||||||
.wallet-page,
|
.wallet-page,
|
||||||
.room-page,
|
.room-page,
|
||||||
.billboard-page {
|
.billboard-page,
|
||||||
|
.billscroll-page {
|
||||||
> .gear-btn {
|
> .gear-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 4.2rem;
|
bottom: 4.2rem;
|
||||||
@@ -102,7 +104,8 @@
|
|||||||
#id_game_applet_menu,
|
#id_game_applet_menu,
|
||||||
#id_game_kit_menu,
|
#id_game_kit_menu,
|
||||||
#id_wallet_applet_menu,
|
#id_wallet_applet_menu,
|
||||||
#id_billboard_applet_menu {
|
#id_billboard_applet_menu,
|
||||||
|
#id_billscroll_menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 6.6rem;
|
bottom: 6.6rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
@@ -118,7 +121,8 @@
|
|||||||
.dashboard-page,
|
.dashboard-page,
|
||||||
.wallet-page,
|
.wallet-page,
|
||||||
.room-page,
|
.room-page,
|
||||||
.billboard-page {
|
.billboard-page,
|
||||||
|
.billscroll-page {
|
||||||
> .gear-btn {
|
> .gear-btn {
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
||||||
@@ -131,7 +135,8 @@
|
|||||||
#id_game_kit_menu,
|
#id_game_kit_menu,
|
||||||
#id_wallet_applet_menu,
|
#id_wallet_applet_menu,
|
||||||
#id_room_menu,
|
#id_room_menu,
|
||||||
#id_billboard_applet_menu {
|
#id_billboard_applet_menu,
|
||||||
|
#id_billscroll_menu {
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
bottom: 6.6rem;
|
bottom: 6.6rem;
|
||||||
top: auto;
|
top: auto;
|
||||||
@@ -144,7 +149,8 @@
|
|||||||
.dashboard-page,
|
.dashboard-page,
|
||||||
.wallet-page,
|
.wallet-page,
|
||||||
.room-page,
|
.room-page,
|
||||||
.billboard-page {
|
.billboard-page,
|
||||||
|
.billscroll-page {
|
||||||
> .gear-btn { right: 2.5rem; }
|
> .gear-btn { right: 2.5rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +159,8 @@
|
|||||||
#id_game_kit_menu,
|
#id_game_kit_menu,
|
||||||
#id_wallet_applet_menu,
|
#id_wallet_applet_menu,
|
||||||
#id_room_menu,
|
#id_room_menu,
|
||||||
#id_billboard_applet_menu { right: 2.5rem; }
|
#id_billboard_applet_menu,
|
||||||
|
#id_billscroll_menu { right: 2.5rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Applet box visual shell (reusable outside the grid) ────
|
// ── Applet box visual shell (reusable outside the grid) ────
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ body.page-billscroll {
|
|||||||
|
|
||||||
.drama-event-body {
|
.drama-event-body {
|
||||||
flex: 0 0 80%;
|
flex: 0 0 80%;
|
||||||
|
|
||||||
|
&.struck {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drama-event-time {
|
.drama-event-time {
|
||||||
|
|||||||
@@ -546,10 +546,28 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
|||||||
|
|
||||||
.table-center {
|
.table-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Gravity settling . . ." / "Levity appraising . . ." shown after a polarity
|
||||||
|
// group confirms their sigs while the other group is still selecting.
|
||||||
|
// Pulsing opacity signals active waiting without being jarring.
|
||||||
|
#id_hex_waiting_msg {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgba(var(--terUser), 0.8);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
animation: hex-wait-pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hex-wait-pulse {
|
||||||
|
0%, 100% { opacity: 0.75; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
.table-seat {
|
.table-seat {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ describe("SigSelect", () => {
|
|||||||
data-polarity="${polarity}"
|
data-polarity="${polarity}"
|
||||||
data-user-role="${userRole}"
|
data-user-role="${userRole}"
|
||||||
data-reserve-url="/epic/room/test/sig-reserve"
|
data-reserve-url="/epic/room/test/sig-reserve"
|
||||||
|
data-ready-url="/epic/room/test/sig-ready"
|
||||||
|
|
||||||
data-reservations="${reservations.replace(/"/g, '"')}">
|
data-reservations="${reservations.replace(/"/g, '"')}">
|
||||||
<div class="sig-modal">
|
<div class="sig-modal">
|
||||||
<div class="sig-stage">
|
<div class="sig-stage">
|
||||||
@@ -605,4 +607,79 @@ describe("SigSelect", () => {
|
|||||||
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── WAIT NVM glow pulse ────────────────────────────────────────────────────── //
|
||||||
|
//
|
||||||
|
// After clicking TAKE SIG (POST ok → isReady=true) a setInterval pulses the
|
||||||
|
// button at 600ms: odd ticks add .btn-cancel + a --terOr outer box-shadow;
|
||||||
|
// even ticks remove both. Uses jasmine.clock() to advance the fake timer.
|
||||||
|
|
||||||
|
describe("WAIT NVM glow pulse", () => {
|
||||||
|
let takeSigBtn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jasmine.clock().install();
|
||||||
|
// Pre-reserve card 42 as PC so _showTakeSigBtn() fires during init
|
||||||
|
makeFixture({ reservations: '{"42":"PC"}' });
|
||||||
|
takeSigBtn = document.getElementById("id_take_sig_btn");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jasmine.clock().uninstall();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function clickTakeSig() {
|
||||||
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
// Flush the fetch .then() so _startWaitNoGlow() is called
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("adds .btn-cancel after the first pulse tick (600 ms)", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601);
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets a non-empty box-shadow after the first pulse tick", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601);
|
||||||
|
expect(takeSigBtn.style.boxShadow).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .btn-cancel on the second tick (even / trough)", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601); // peak
|
||||||
|
jasmine.clock().tick(600); // trough
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears box-shadow on the trough tick", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601);
|
||||||
|
jasmine.clock().tick(600);
|
||||||
|
expect(takeSigBtn.style.boxShadow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops glow and removes .btn-cancel when WAIT NVM is clicked (unready)", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601); // glow is on
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(true);
|
||||||
|
|
||||||
|
// Click again → WAIT NVM → fetch unready → _stopWaitNoGlow()
|
||||||
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
||||||
|
expect(takeSigBtn.style.boxShadow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("glow does not advance after being stopped", async () => {
|
||||||
|
await clickTakeSig();
|
||||||
|
jasmine.clock().tick(601); // peak
|
||||||
|
takeSigBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve(); // stop
|
||||||
|
jasmine.clock().tick(600); // would be another tick if running
|
||||||
|
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<a href="{% url 'billboard:scroll' recent_room.id %}" class="most-recent-load-more">Load more….</a>
|
<a href="{% url 'billboard:scroll' recent_room.id %}" class="most-recent-load-more">Load more….</a>
|
||||||
{% for event in recent_events %}
|
{% for event in recent_events %}
|
||||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
||||||
<span class="drama-event-body">
|
<span class="drama-event-body{% if event.struck %} struck{% endif %}">
|
||||||
<strong>{{ event.actor|display_name }}</strong>
|
<strong>{{ event.actor|display_name }}</strong>
|
||||||
{{ event.to_prose }}
|
{{ event.to_prose|safe }}
|
||||||
</span>
|
</span>
|
||||||
<time class="drama-event-time" datetime="{{ event.timestamp|date:'c' }}">
|
<time class="drama-event-time" datetime="{{ event.timestamp|date:'c' }}">
|
||||||
{{ event.timestamp|relative_ts }}
|
{{ event.timestamp|relative_ts }}
|
||||||
|
|||||||
@@ -6,6 +6,76 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="billscroll-page">
|
<div class="billscroll-page">
|
||||||
|
{% include "apps/applets/_partials/_gear.html" with menu_id="id_billscroll_menu" %}
|
||||||
|
<div id="id_billscroll_menu" style="display:none;">
|
||||||
|
<form id="id_scroll_filter_form">
|
||||||
|
<label><input type="checkbox" name="labels" value="frame" checked> Frame</label>
|
||||||
|
<label><input type="checkbox" name="labels" value="redact" checked> Redact</label>
|
||||||
|
<div class="menu-btns">
|
||||||
|
<button type="submit" class="btn btn-confirm">OK</button>
|
||||||
|
<button type="button" class="btn btn-cancel applet-menu-cancel">NVM</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
|
{% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var STORAGE_KEY = 'billscroll-labels-{{ room.id }}';
|
||||||
|
var ALL_LABELS = ['frame', 'redact'];
|
||||||
|
|
||||||
|
function applyFilter(checked) {
|
||||||
|
document.querySelectorAll(
|
||||||
|
'#id_drama_scroll .drama-event[data-label]'
|
||||||
|
).forEach(function (el) {
|
||||||
|
el.style.display = checked.indexOf(el.dataset.label) !== -1 ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFilter(checked) {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(checked)); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFilter() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch (_) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCheckboxes(checked) {
|
||||||
|
var form = document.getElementById('id_scroll_filter_form');
|
||||||
|
if (!form) return;
|
||||||
|
form.querySelectorAll('input[name="labels"]').forEach(function (cb) {
|
||||||
|
cb.checked = checked.indexOf(cb.value) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore saved filter on page load
|
||||||
|
var saved = loadFilter();
|
||||||
|
if (saved) {
|
||||||
|
applyFilter(saved);
|
||||||
|
syncCheckboxes(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply + save on OK
|
||||||
|
var form = document.getElementById('id_scroll_filter_form');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var checked = Array.from(
|
||||||
|
form.querySelectorAll('input[name="labels"]:checked')
|
||||||
|
).map(function (cb) { return cb.value; });
|
||||||
|
applyFilter(checked);
|
||||||
|
saveFilter(checked);
|
||||||
|
// Close the menu (mirror applets.js close logic)
|
||||||
|
var menu = document.getElementById('id_billscroll_menu');
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
var gear = document.querySelector(
|
||||||
|
'.gear-btn[data-menu-target="id_billscroll_menu"]'
|
||||||
|
);
|
||||||
|
if (gear) gear.classList.remove('active');
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
|
|||||||
data-polarity="{{ user_polarity }}"
|
data-polarity="{{ user_polarity }}"
|
||||||
data-user-role="{{ user_seat.role }}"
|
data-user-role="{{ user_seat.role }}"
|
||||||
data-reserve-url="{{ sig_reserve_url }}"
|
data-reserve-url="{{ sig_reserve_url }}"
|
||||||
|
data-ready-url="{% url 'epic:sig_ready' room.id %}"
|
||||||
|
|
||||||
|
data-ready="{{ user_ready|yesno:'true,false' }}"
|
||||||
data-reservations="{{ sig_reservations_json }}">
|
data-reservations="{{ sig_reservations_json }}">
|
||||||
|
|
||||||
<div class="sig-modal">
|
<div class="sig-modal">
|
||||||
|
|||||||
@@ -36,6 +36,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if room.table_status == "SKY_SELECT" %}
|
{% if room.table_status == "SKY_SELECT" %}
|
||||||
<button id="id_pick_sky_btn" class="btn btn-primary">PICK<br>SKY</button>
|
<button id="id_pick_sky_btn" class="btn btn-primary">PICK<br>SKY</button>
|
||||||
|
{% elif room.table_status == "SIG_SELECT" %}
|
||||||
|
<button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">PICK<br>SKY</button>
|
||||||
|
{% if polarity_done %}
|
||||||
|
<p id="id_hex_waiting_msg">{% if user_polarity == "levity" %}Gravity settling . . .{% else %}Levity appraising . . .{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,8 +62,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Sig Select overlay — only shown to seated gamers in this polarity #}
|
{# Sig Select overlay — suppressed once this gamer's polarity sigs are assigned #}
|
||||||
{% if room.table_status == "SIG_SELECT" and user_polarity %}
|
{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done %}
|
||||||
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
|
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% load lyric_extras %}
|
{% load lyric_extras %}
|
||||||
<section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
|
<section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
|
||||||
{% for event in events %}
|
{% for event in events %}
|
||||||
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
|
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}" data-label="{% if event.struck %}redact{% else %}frame{% endif %}">
|
||||||
<span class="drama-event-body">
|
<span class="drama-event-body{% if event.struck %} struck{% endif %}">
|
||||||
<strong>{{ event.actor|display_name }}</strong>
|
<strong>{{ event.actor|display_name }}</strong>
|
||||||
{{ event.to_prose }}
|
{{ event.to_prose|safe }}
|
||||||
</span>
|
</span>
|
||||||
<time class="drama-event-time" datetime="{{ event.timestamp|date:'c' }}">
|
<time class="drama-event-time" datetime="{{ event.timestamp|date:'c' }}">
|
||||||
{{ event.timestamp|relative_ts }}
|
{{ event.timestamp|relative_ts }}
|
||||||
|
|||||||
Reference in New Issue
Block a user