Compare commits
7 Commits
379e0ab80c
...
e084bcc2d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e084bcc2d5 | ||
|
|
08aa4dc819 | ||
|
|
2af59b3a7f | ||
|
|
6d75b9541f | ||
|
|
132e60864e | ||
|
|
ff3e4d295c | ||
|
|
39e12d6a3d |
@@ -90,5 +90,8 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def pick_sky_available(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sky_confirmed(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def cursor_move(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
49
src/apps/epic/migrations/0010_major_arcana_hand_dots_icon.py
Normal file
49
src/apps/epic/migrations/0010_major_arcana_hand_dots_icon.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Assign fa-hand-dots icon to all Earthman Major Arcana cards with number >= 2.
|
||||
|
||||
Cards 0 (The Nomad) and 1 (The Schizo) keep their existing icon value so they
|
||||
can receive distinct icons later. All other Major Arcana groups (Popes, Implicit
|
||||
Virtues, Elements, Realms, Explicit Virtues, Zodiac, Lunars, Planets, Inner Rings,
|
||||
polarity-split finals) default to fa-hand-dots until per-group icons are assigned.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def assign_hand_dots(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman,
|
||||
arcana="MAJOR",
|
||||
number__gte=2,
|
||||
icon="",
|
||||
).update(icon="fa-hand-dots")
|
||||
|
||||
|
||||
def clear_hand_dots(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman,
|
||||
arcana="MAJOR",
|
||||
number__gte=2,
|
||||
icon="fa-hand-dots",
|
||||
).update(icon="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0009_schizo_card_ref_spans"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(assign_hand_dots, reverse_code=clear_hand_dots),
|
||||
]
|
||||
43
src/apps/epic/migrations/0011_nomad_schizo_icons.py
Normal file
43
src/apps/epic/migrations/0011_nomad_schizo_icons.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Assign individual icons to The Nomad (0) and The Schizo (1).
|
||||
|
||||
All other Major Arcana already have fa-hand-dots from migration 0010.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
ICONS = {0: 'fa-hat-cowboy', 1: 'fa-hat-wizard'}
|
||||
|
||||
|
||||
def assign_icons(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
for number, icon in ICONS.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(icon=icon)
|
||||
|
||||
|
||||
def clear_icons(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number__in=list(ICONS.keys())
|
||||
).update(icon="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0010_major_arcana_hand_dots_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(assign_icons, reverse_code=clear_icons),
|
||||
]
|
||||
27
src/apps/epic/migrations/0012_delete_stray_pentacles.py
Normal file
27
src/apps/epic/migrations/0012_delete_stray_pentacles.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Delete 4 stray PENTACLES court cards from the Earthman deck.
|
||||
|
||||
These survived the migration collapse; the Earthman deck uses
|
||||
BRANDS/GRAILS/BLADES/CROWNS only.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def delete_pentacles(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0011_nomad_schizo_icons"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_pentacles, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -285,7 +285,9 @@ class TarotCard(models.Model):
|
||||
if self.arcana == self.MAJOR:
|
||||
return self._to_roman(self.number)
|
||||
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
|
||||
return court.get(self.number, str(self.number))
|
||||
if self.number in court:
|
||||
return court[self.number]
|
||||
return 'A' if self.number == 1 else str(self.number)
|
||||
|
||||
def emanation_for(self, polarity):
|
||||
"""Return the upright title for a given polarity ('levity' or 'gravity').
|
||||
|
||||
314
src/apps/epic/static/apps/epic/sea.js
Normal file
314
src/apps/epic/static/apps/epic/sea.js
Normal file
@@ -0,0 +1,314 @@
|
||||
var SeaDeal = (function () {
|
||||
'use strict';
|
||||
|
||||
var overlay, stage, stageCard, statBlock;
|
||||
var spinBtn, fyiBtn, bdrop;
|
||||
var fyiPanel, fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext;
|
||||
|
||||
var _userPolarity = 'levity';
|
||||
var _seaHand = {}; // posSelector → {card, isLevity}
|
||||
var _viewingPos = null;
|
||||
var _infoData = [];
|
||||
var _infoIdx = 0;
|
||||
var _infoOpen = false;
|
||||
var _spinOrigLabel, _fyiOrigLabel;
|
||||
|
||||
// ── Keyword list ──────────────────────────────────────────────────────────
|
||||
|
||||
function _populateList(listEl, items) {
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = (items || []).map(function (k) {
|
||||
return '<li>' + k + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Stage card population ─────────────────────────────────────────────────
|
||||
|
||||
function _populate(card, isLevity) {
|
||||
var qualifier = isLevity
|
||||
? (card.levity_qualifier || '')
|
||||
: (card.gravity_qualifier || '');
|
||||
var isMajor = card.arcana === 'MAJOR';
|
||||
var title = card.name_title || card.name || '';
|
||||
|
||||
// Corners
|
||||
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) {
|
||||
el.textContent = card.corner_rank || '';
|
||||
});
|
||||
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||
if (card.suit_icon) {
|
||||
el.className = 'fa-solid ' + card.suit_icon + ' stage-suit-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Upright face
|
||||
var nameGroupEl = stageCard.querySelector('.fan-card-name-group');
|
||||
if (nameGroupEl) nameGroupEl.textContent = card.name_group || '';
|
||||
var arcanaEl = stageCard.querySelector('.fan-card-arcana');
|
||||
if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana';
|
||||
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||
stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier;
|
||||
stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : '';
|
||||
|
||||
// Reversal face (same slot-swap logic as sig-select)
|
||||
var reversal = card.reversal || '';
|
||||
if (isMajor) {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = title + ',';
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = qualifier;
|
||||
} else if (reversal) {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal;
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
} else {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier;
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
}
|
||||
|
||||
// Keywords
|
||||
_populateList(overlay.querySelector('#id_sea_stat_upright'), card.keywords_upright);
|
||||
_populateList(overlay.querySelector('#id_sea_stat_reversed'), card.keywords_reversed);
|
||||
|
||||
// FYI data (energies + operations)
|
||||
_infoData = (card.energies || []).map(function (e) {
|
||||
return { type: e.type, effect: e.effect, category: 'energies' };
|
||||
}).concat((card.operations || []).map(function (o) {
|
||||
return { type: o.type, effect: o.effect, category: 'operations' };
|
||||
}));
|
||||
_infoIdx = 0;
|
||||
|
||||
// Reset SPIN
|
||||
stageCard.classList.remove('stage-card--reversed');
|
||||
statBlock.classList.remove('is-reversed');
|
||||
_closeInfo();
|
||||
}
|
||||
|
||||
// ── FYI info panel ────────────────────────────────────────────────────────
|
||||
|
||||
function _renderInfo() {
|
||||
if (!fyiPanel) return;
|
||||
if (_infoData.length === 0) {
|
||||
fyiTitle.textContent = 'Energy';
|
||||
fyiTitle.className = 'sig-info-title sig-info-title--energies';
|
||||
if (fyiType) fyiType.textContent = '';
|
||||
if (fyiEffect) fyiEffect.innerHTML = '<em>No interactions defined.</em>';
|
||||
fyiIndex.textContent = '';
|
||||
return;
|
||||
}
|
||||
var entry = _infoData[_infoIdx];
|
||||
var isEnergies = entry.category === 'energies';
|
||||
fyiTitle.textContent = isEnergies ? 'Energy' : 'Operation';
|
||||
fyiTitle.className = 'sig-info-title sig-info-title--' + entry.category;
|
||||
if (fyiType) fyiType.textContent = entry.type || '';
|
||||
if (fyiEffect) fyiEffect.innerHTML = entry.effect || '';
|
||||
fyiIndex.textContent = _infoData.length > 1
|
||||
? (_infoIdx + 1) + ' / ' + _infoData.length : '';
|
||||
}
|
||||
|
||||
function _openInfo() {
|
||||
_infoOpen = true;
|
||||
_renderInfo();
|
||||
if (fyiPanel) fyiPanel.style.display = 'flex';
|
||||
if (spinBtn) { spinBtn.classList.add('btn-disabled'); spinBtn.textContent = '×'; }
|
||||
if (fyiBtn) { fyiBtn.classList.add('btn-disabled'); fyiBtn.textContent = '×'; }
|
||||
stage.classList.add('sea-info-open');
|
||||
}
|
||||
|
||||
function _closeInfo() {
|
||||
_infoOpen = false;
|
||||
if (fyiPanel) fyiPanel.style.display = 'none';
|
||||
if (spinBtn) { spinBtn.classList.remove('btn-disabled'); spinBtn.textContent = _spinOrigLabel || 'SPIN'; }
|
||||
if (fyiBtn) { fyiBtn.classList.remove('btn-disabled'); fyiBtn.textContent = _fyiOrigLabel || 'FYI'; }
|
||||
if (stage) stage.classList.remove('sea-info-open');
|
||||
}
|
||||
|
||||
// ── Slot fill ─────────────────────────────────────────────────────────────
|
||||
|
||||
function _fillSlot(posSelector, card, isLevity) {
|
||||
var cell = overlay.querySelector(posSelector);
|
||||
if (!cell) return;
|
||||
var slot = cell.querySelector('.sea-card-slot');
|
||||
if (!slot) return;
|
||||
slot.classList.remove('sea-card-slot--empty');
|
||||
slot.classList.add('sea-card-slot--filled');
|
||||
slot.classList.add(isLevity ? 'sea-card-slot--levity' : 'sea-card-slot--gravity');
|
||||
slot.dataset.cardId = String(card.id);
|
||||
slot.dataset.posKey = posSelector;
|
||||
slot.innerHTML =
|
||||
'<span class="fan-corner-rank">' + card.corner_rank + '</span>' +
|
||||
(card.suit_icon ? '<i class="fa-solid ' + card.suit_icon + '"></i>' : '');
|
||||
}
|
||||
|
||||
// ── Show / hide stage ─────────────────────────────────────────────────────
|
||||
|
||||
function _showStage(isLevity) {
|
||||
stage.style.display = '';
|
||||
stage.classList.toggle('sea-stage--levity', !!isLevity);
|
||||
stage.classList.toggle('sea-stage--gravity', !isLevity);
|
||||
stageCard.classList.add('sea-stage-card--shown');
|
||||
}
|
||||
|
||||
function _hideStage() {
|
||||
// Reveal the deposited card in its slot (opacity 0 → 0.6 transition)
|
||||
if (_viewingPos) {
|
||||
var cell = overlay.querySelector(_viewingPos);
|
||||
if (cell) {
|
||||
var slot = cell.querySelector('.sea-card-slot--filled');
|
||||
if (slot) slot.classList.add('sea-card-slot--visible');
|
||||
}
|
||||
}
|
||||
stage.style.display = 'none';
|
||||
stageCard.classList.remove('sea-stage-card--shown');
|
||||
_viewingPos = null;
|
||||
_closeInfo();
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
function openStage(card, posSelector, isLevity) {
|
||||
_viewingPos = posSelector;
|
||||
_seaHand[posSelector] = { card: card, isLevity: isLevity };
|
||||
_populate(card, isLevity);
|
||||
_fillSlot(posSelector, card, isLevity);
|
||||
_showStage(isLevity);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
overlay = document.getElementById('id_sea_overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
stage = overlay.querySelector('#id_sea_stage');
|
||||
stageCard = stage && stage.querySelector('.sea-stage-card');
|
||||
statBlock = stage && stage.querySelector('.sea-stat-block');
|
||||
if (!stage || !stageCard || !statBlock) return;
|
||||
|
||||
_userPolarity = overlay.dataset.seaUserPolarity || 'levity';
|
||||
|
||||
spinBtn = statBlock.querySelector('.sea-spin-btn');
|
||||
fyiBtn = statBlock.querySelector('.sea-fyi-btn');
|
||||
bdrop = stage.querySelector('.sea-stage-backdrop');
|
||||
|
||||
fyiPanel = overlay.querySelector('#id_sea_fyi_panel');
|
||||
fyiTitle = fyiPanel && fyiPanel.querySelector('.sig-info-title');
|
||||
fyiType = fyiPanel && fyiPanel.querySelector('.sig-info-type');
|
||||
fyiEffect = fyiPanel && fyiPanel.querySelector('.sig-info-effect');
|
||||
fyiIndex = fyiPanel && fyiPanel.querySelector('.sig-info-index');
|
||||
fyiPrev = statBlock.querySelector('.sea-fyi-prev');
|
||||
fyiNext = statBlock.querySelector('.sea-fyi-next');
|
||||
|
||||
_spinOrigLabel = spinBtn ? spinBtn.textContent : 'SPIN';
|
||||
_fyiOrigLabel = fyiBtn ? fyiBtn.textContent : 'FYI';
|
||||
|
||||
// SPIN
|
||||
if (spinBtn) {
|
||||
spinBtn.addEventListener('click', function () {
|
||||
if (spinBtn.classList.contains('btn-disabled')) return;
|
||||
statBlock.classList.toggle('is-reversed');
|
||||
// Remove animation fill, force reflow so the transition has a start state
|
||||
stageCard.classList.remove('sea-stage-card--shown');
|
||||
stageCard.getBoundingClientRect(); // flush layout
|
||||
stageCard.classList.toggle('stage-card--reversed');
|
||||
});
|
||||
}
|
||||
|
||||
// FYI
|
||||
if (fyiBtn) {
|
||||
fyiBtn.addEventListener('click', function () {
|
||||
if (fyiBtn.classList.contains('btn-disabled')) return;
|
||||
_infoOpen ? _closeInfo() : _openInfo();
|
||||
});
|
||||
}
|
||||
|
||||
// Clicking the FYI panel itself dismisses it (same as sig-select caution)
|
||||
if (fyiPanel) {
|
||||
fyiPanel.addEventListener('click', function (e) {
|
||||
if (!e.target.closest('.sea-fyi-prev') && !e.target.closest('.sea-fyi-next')) {
|
||||
_closeInfo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clicking the FYI panel closes it (same pattern as sig-select)
|
||||
if (fyiPanel) fyiPanel.addEventListener('click', _closeInfo);
|
||||
|
||||
// FYI nav
|
||||
if (fyiPrev) {
|
||||
fyiPrev.addEventListener('click', function () {
|
||||
_infoIdx = (_infoIdx - 1 + _infoData.length) % _infoData.length;
|
||||
_renderInfo();
|
||||
});
|
||||
}
|
||||
if (fyiNext) {
|
||||
fyiNext.addEventListener('click', function () {
|
||||
_infoIdx = (_infoIdx + 1) % _infoData.length;
|
||||
_renderInfo();
|
||||
});
|
||||
}
|
||||
|
||||
// Backdrop dismiss
|
||||
if (bdrop) {
|
||||
bdrop.addEventListener('click', _hideStage);
|
||||
}
|
||||
|
||||
// Overlay click — handle slot focus/open two-step tap pattern
|
||||
// (deck-stack _hideOk is registered separately from the inline template script)
|
||||
overlay.addEventListener('click', function (e) {
|
||||
var slot = e.target.closest('.sea-card-slot--filled');
|
||||
if (!slot) {
|
||||
// Clicked outside any filled slot — unfocus all
|
||||
overlay.querySelectorAll('.sea-card-slot--focused').forEach(function (s) {
|
||||
s.classList.remove('sea-card-slot--focused');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var pos = slot.dataset.posKey;
|
||||
if (!pos || !_seaHand[pos]) return;
|
||||
|
||||
if (slot.classList.contains('sea-card-slot--focused')) {
|
||||
// Second tap/click — open modal
|
||||
var h = _seaHand[pos];
|
||||
_viewingPos = pos;
|
||||
_populate(h.card, h.isLevity);
|
||||
_showStage(h.isLevity);
|
||||
} else {
|
||||
// First tap/click — focus (persist opacity 1)
|
||||
overlay.querySelectorAll('.sea-card-slot--focused').forEach(function (s) {
|
||||
s.classList.remove('sea-card-slot--focused');
|
||||
});
|
||||
slot.classList.add('sea-card-slot--focused');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
function resetHand() {
|
||||
_seaHand = {};
|
||||
_viewingPos = null;
|
||||
_hideStage();
|
||||
}
|
||||
|
||||
return {
|
||||
openStage: openStage,
|
||||
resetHand: resetHand,
|
||||
reinit: init, // call after overlay is injected into the DOM
|
||||
_testInit: function () {
|
||||
overlay = null; stage = null; stageCard = null; statBlock = null;
|
||||
spinBtn = null; fyiBtn = null; bdrop = null;
|
||||
fyiPanel = null; fyiTitle = null; fyiType = null;
|
||||
fyiEffect = null; fyiIndex = null; fyiPrev = null; fyiNext = null;
|
||||
_userPolarity = 'levity';
|
||||
_seaHand = {}; _viewingPos = null;
|
||||
_infoData = []; _infoIdx = 0; _infoOpen = false;
|
||||
init();
|
||||
},
|
||||
};
|
||||
}());
|
||||
@@ -157,7 +157,7 @@ var SigSelect = (function () {
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
} else {
|
||||
stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier;
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = '';
|
||||
stageCard.querySelector('.fan-card-reversal-name').textContent = title;
|
||||
}
|
||||
|
||||
// Populate stat block keyword faces and reset to upright
|
||||
|
||||
@@ -1855,6 +1855,16 @@ class PickSeaRenderingTest(TestCase):
|
||||
self.assertIn("user_polarity", response.context)
|
||||
self.assertEqual(response.context["user_polarity"], "levity") # PC is levity
|
||||
|
||||
def test_my_tray_sig_falls_back_to_seat_when_char_sig_is_none(self):
|
||||
"""Characters created before the sig-sync fix have significator=None; fall back to seat."""
|
||||
Character.objects.create(
|
||||
seat=self.pc_seat,
|
||||
significator=None,
|
||||
confirmed_at=timezone.now(),
|
||||
)
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.context["my_tray_sig"], self.sig_card)
|
||||
|
||||
def test_my_tray_sig_comes_from_character_significator_when_confirmed(self):
|
||||
"""When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat)."""
|
||||
char = Character.objects.create(
|
||||
|
||||
72
src/apps/epic/tests/unit/test_models.py
Normal file
72
src/apps/epic/tests/unit/test_models.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.epic.models import TarotCard
|
||||
|
||||
|
||||
def _card(arcana, number, suit='', icon=''):
|
||||
c = TarotCard()
|
||||
c.arcana = arcana
|
||||
c.number = number
|
||||
c.suit = suit
|
||||
c.icon = icon
|
||||
return c
|
||||
|
||||
|
||||
class TarotCardCornerRankTest(SimpleTestCase):
|
||||
"""TarotCard.corner_rank — alphanumeric display labels."""
|
||||
|
||||
def test_major_arcana_0_gives_0(self):
|
||||
self.assertEqual(_card('MAJOR', 0).corner_rank, '0')
|
||||
|
||||
def test_major_arcana_1_gives_roman_I(self):
|
||||
self.assertEqual(_card('MAJOR', 1).corner_rank, 'I')
|
||||
|
||||
def test_major_arcana_2_gives_roman_II(self):
|
||||
self.assertEqual(_card('MAJOR', 2).corner_rank, 'II')
|
||||
|
||||
def test_non_major_pip_1_gives_A(self):
|
||||
"""Ace — pip card number 1 should show 'A', not '1'."""
|
||||
self.assertEqual(_card('MIDDLE', 1, 'BRANDS').corner_rank, 'A')
|
||||
|
||||
def test_non_major_pip_2_gives_2(self):
|
||||
self.assertEqual(_card('MIDDLE', 2, 'BRANDS').corner_rank, '2')
|
||||
|
||||
def test_non_major_pip_10_gives_10(self):
|
||||
self.assertEqual(_card('MIDDLE', 10, 'CROWNS').corner_rank, '10')
|
||||
|
||||
def test_court_maid_gives_M(self):
|
||||
self.assertEqual(_card('MIDDLE', 11, 'GRAILS').corner_rank, 'M')
|
||||
|
||||
def test_court_jack_gives_J(self):
|
||||
self.assertEqual(_card('MIDDLE', 12, 'BLADES').corner_rank, 'J')
|
||||
|
||||
def test_court_queen_gives_Q(self):
|
||||
self.assertEqual(_card('MIDDLE', 13, 'BRANDS').corner_rank, 'Q')
|
||||
|
||||
def test_court_king_gives_K(self):
|
||||
self.assertEqual(_card('MIDDLE', 14, 'CROWNS').corner_rank, 'K')
|
||||
|
||||
|
||||
class TarotCardSuitIconTest(SimpleTestCase):
|
||||
"""TarotCard.suit_icon — icon class resolution."""
|
||||
|
||||
def test_major_with_icon_returns_icon(self):
|
||||
self.assertEqual(_card('MAJOR', 0, icon='fa-hat-cowboy').suit_icon, 'fa-hat-cowboy')
|
||||
|
||||
def test_major_without_icon_returns_empty(self):
|
||||
self.assertEqual(_card('MAJOR', 5).suit_icon, '')
|
||||
|
||||
def test_brands_returns_wand_sparkles(self):
|
||||
self.assertEqual(_card('MIDDLE', 11, 'BRANDS').suit_icon, 'fa-wand-sparkles')
|
||||
|
||||
def test_grails_returns_trophy(self):
|
||||
self.assertEqual(_card('MIDDLE', 11, 'GRAILS').suit_icon, 'fa-trophy')
|
||||
|
||||
def test_blades_returns_gun(self):
|
||||
self.assertEqual(_card('MIDDLE', 11, 'BLADES').suit_icon, 'fa-gun')
|
||||
|
||||
def test_crowns_returns_crown(self):
|
||||
self.assertEqual(_card('MIDDLE', 11, 'CROWNS').suit_icon, 'fa-crown')
|
||||
|
||||
def test_icon_override_takes_priority_over_suit(self):
|
||||
self.assertEqual(_card('MIDDLE', 11, 'CROWNS', icon='fa-star').suit_icon, 'fa-star')
|
||||
@@ -27,4 +27,6 @@ urlpatterns = [
|
||||
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
|
||||
path('room/<uuid:room_id>/natus/preview', views.natus_preview, name='natus_preview'),
|
||||
path('room/<uuid:room_id>/natus/save', views.natus_save, name='natus_save'),
|
||||
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
|
||||
path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'),
|
||||
]
|
||||
|
||||
@@ -126,6 +126,13 @@ def _notify_pick_sky_available(room_id):
|
||||
)
|
||||
|
||||
|
||||
def _notify_sky_confirmed(room_id, seat_role):
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'room_{room_id}',
|
||||
{'type': 'sky_confirmed', 'seat_role': seat_role},
|
||||
)
|
||||
|
||||
|
||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
|
||||
_SIG_SEAT_ORDERING = Case(
|
||||
@@ -362,8 +369,10 @@ def _role_select_context(room, user):
|
||||
)
|
||||
sky_confirmed = confirmed_char is not None
|
||||
ctx["sky_confirmed"] = sky_confirmed
|
||||
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
|
||||
if sky_confirmed:
|
||||
ctx["my_tray_sig"] = confirmed_char.significator
|
||||
# Fall back to seat.significator for Characters created before the sync was added
|
||||
ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -1093,5 +1102,74 @@ def natus_save(request, room_id):
|
||||
char.confirmed_at = timezone.now()
|
||||
|
||||
char.save()
|
||||
|
||||
if char.is_confirmed:
|
||||
_notify_sky_confirmed(room_id, seat.role)
|
||||
|
||||
return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed})
|
||||
|
||||
|
||||
@login_required
|
||||
def sea_deck(request, room_id):
|
||||
"""Shuffled deck lists (levity + gravity halves) for PICK SEA draw.
|
||||
|
||||
Excludes all Significators already claimed by seated gamers.
|
||||
Returns {levity: [{id, name, arcana, suit, number, levity_qualifier,
|
||||
gravity_qualifier}], gravity: [...]}
|
||||
"""
|
||||
import random as _random
|
||||
room = Room.objects.get(id=room_id)
|
||||
seat = _canonical_user_seat(room, request.user)
|
||||
if seat is None:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
deck = seat.deck_variant
|
||||
if not deck:
|
||||
return JsonResponse({'levity': [], 'gravity': []})
|
||||
|
||||
sig_ids = set(
|
||||
room.table_seats.exclude(significator__isnull=True)
|
||||
.values_list('significator_id', flat=True)
|
||||
)
|
||||
|
||||
def _card_dict(c):
|
||||
return {
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'arcana': c.arcana,
|
||||
'suit': c.suit,
|
||||
'number': c.number,
|
||||
'corner_rank': c.corner_rank,
|
||||
'suit_icon': c.suit_icon,
|
||||
'name_group': c.name_group,
|
||||
'name_title': c.name_title,
|
||||
'levity_qualifier': c.levity_qualifier,
|
||||
'gravity_qualifier': c.gravity_qualifier,
|
||||
'reversal': c.reversal,
|
||||
'keywords_upright': c.keywords_upright,
|
||||
'keywords_reversed': c.keywords_reversed,
|
||||
'energies': c.energies,
|
||||
'operations': c.operations,
|
||||
}
|
||||
|
||||
available = list(
|
||||
TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
|
||||
)
|
||||
_random.shuffle(available)
|
||||
mid = len(available) // 2
|
||||
return JsonResponse({
|
||||
'levity': [_card_dict(c) for c in available[:mid]],
|
||||
'gravity': [_card_dict(c) for c in available[mid:]],
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def sea_partial(request, room_id):
|
||||
"""Return the rendered sea overlay partial for in-page injection after sky confirm."""
|
||||
room = Room.objects.get(id=room_id)
|
||||
ctx = _role_select_context(room, request.user)
|
||||
if not ctx.get('sky_confirmed'):
|
||||
return HttpResponse(status=403)
|
||||
ctx['room'] = room
|
||||
return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx)
|
||||
|
||||
|
||||
337
src/functional_tests/test_room_sea_select.py
Normal file
337
src/functional_tests/test_room_sea_select.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""Functional tests for the PICK SEA overlay — Celtic Cross draw."""
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.epic.models import Character, GateSlot, Room, TableSeat, TarotCard, DeckVariant
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import ChannelsFunctionalTest
|
||||
|
||||
|
||||
def _make_sky_confirmed_room(live_server_url, user, earthman):
|
||||
"""Create a SKY_SELECT room with one gamer seated and sig assigned.
|
||||
|
||||
Returns (room, seat). The Character is NOT yet confirmed — call
|
||||
_confirm_sky() in the browser to trigger the async transition.
|
||||
"""
|
||||
room = Room.objects.create(
|
||||
name="Sea Test Room", table_status=Room.SKY_SELECT, owner=user
|
||||
)
|
||||
slot = room.gate_slots.get(slot_number=1)
|
||||
slot.gamer = user
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.save()
|
||||
room.gate_status = Room.OPEN
|
||||
room.save()
|
||||
|
||||
sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first()
|
||||
seat = TableSeat.objects.create(
|
||||
room=room, gamer=user, role="PC", slot_number=1,
|
||||
deck_variant=earthman, significator=sig_card,
|
||||
)
|
||||
return room, seat
|
||||
|
||||
|
||||
class PickSeaAsyncTransitionTest(ChannelsFunctionalTest):
|
||||
"""After sky confirm, PICK SEA overlay appears without a page refresh."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.browser.set_window_size(800, 1200)
|
||||
Applet.objects.get_or_create(
|
||||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||
)
|
||||
Applet.objects.get_or_create(
|
||||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||
)
|
||||
from apps.lyric.models import User
|
||||
gamer, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
gamer.unlocked_decks.add(earthman)
|
||||
gamer.equipped_deck = earthman
|
||||
gamer.save(update_fields=["equipped_deck"])
|
||||
|
||||
self.gamer = gamer
|
||||
self.room, self.seat = _make_sky_confirmed_room(self.live_server_url, gamer, earthman)
|
||||
self.room_url = self.live_server_url + reverse(
|
||||
"epic:room", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
self.natus_save_url = self.live_server_url + reverse(
|
||||
"epic:natus_save", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
|
||||
def _confirm_sky(self):
|
||||
"""POST to natus_save with action=confirm from browser JS (bypasses chart form)."""
|
||||
# Wait for the room WS connection to be ready before triggering confirm
|
||||
self.wait_for(lambda: self.browser.execute_script(
|
||||
"return !!(window._roomSocket && window._roomSocket.readyState === 1);"
|
||||
))
|
||||
self.browser.execute_script(f"""
|
||||
const csrf = (document.cookie.match(/csrftoken=([^;]+)/) || ['',''])[1];
|
||||
fetch('{self.natus_save_url}', {{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {{'Content-Type': 'application/json', 'X-CSRFToken': csrf}},
|
||||
body: JSON.stringify({{
|
||||
birth_dt: '1990-06-15T09:00:00Z',
|
||||
birth_lat: 51.5, birth_lon: -0.1,
|
||||
birth_place: 'London', house_system: 'O',
|
||||
chart_data: {{}}, action: 'confirm',
|
||||
}}),
|
||||
}});
|
||||
""")
|
||||
|
||||
def test_sea_overlay_appears_without_page_refresh(self):
|
||||
"""Confirming sky replaces the natus overlay with the sea overlay in-place."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
|
||||
# Sky not yet confirmed — PICK SKY btn present, no sea overlay
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
self.assertEqual(self.browser.find_elements(By.ID, "id_sea_overlay"), [])
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
# Sea overlay appears without page refresh
|
||||
sea_overlay = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_sea_overlay")
|
||||
)
|
||||
self.assertTrue(sea_overlay.is_displayed())
|
||||
|
||||
def test_natus_overlay_not_visible_after_sky_confirm(self):
|
||||
"""Natus overlay is removed from the DOM after sky confirm."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
# Sea overlay must appear first (confirms transition happened)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
|
||||
|
||||
natus = self.browser.find_elements(By.ID, "id_natus_overlay")
|
||||
self.assertTrue(not natus or not natus[0].is_displayed())
|
||||
|
||||
def test_sea_open_class_on_html_after_confirm(self):
|
||||
"""html.sea-open is set after sky confirm, giving the sea overlay its backdrop."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||
|
||||
self._confirm_sky()
|
||||
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
|
||||
has_sea_open = self.browser.execute_script(
|
||||
"return document.documentElement.classList.contains('sea-open');"
|
||||
)
|
||||
self.assertTrue(has_sea_open)
|
||||
|
||||
|
||||
# ── Helpers for PICK SEA deal tests ──────────────────────────────────────────
|
||||
|
||||
def _seed_earthman_cards(earthman, count=20):
|
||||
"""Seed enough Middle Arcana cards for the deck piles."""
|
||||
suits = ["BRANDS", "GRAILS", "BLADES", "CROWNS"]
|
||||
nums = [11, 12, 13, 14]
|
||||
for suit in suits:
|
||||
for num in nums:
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman,
|
||||
slug=f"m{num}-{suit.lower()}-em",
|
||||
defaults={"arcana": "MIDDLE", "suit": suit, "number": num,
|
||||
"name": f"Card {num} {suit}"},
|
||||
)
|
||||
|
||||
|
||||
def _make_sea_ready_room(earthman):
|
||||
"""Create a SKY_SELECT room with a confirmed Character ready for PICK SEA.
|
||||
|
||||
Returns (room, gamer, seat, char, room_url).
|
||||
"""
|
||||
gamer, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
gamer.unlocked_decks.add(earthman)
|
||||
gamer.equipped_deck = earthman
|
||||
gamer.save(update_fields=["equipped_deck"])
|
||||
|
||||
room = Room.objects.create(
|
||||
name="Sea Deal Room", table_status=Room.SKY_SELECT, owner=gamer
|
||||
)
|
||||
slot = room.gate_slots.get(slot_number=1)
|
||||
slot.gamer = gamer
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.save()
|
||||
room.gate_status = Room.OPEN
|
||||
room.save()
|
||||
|
||||
sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first()
|
||||
seat = TableSeat.objects.create(
|
||||
room=room, gamer=gamer, role="PC", slot_number=1,
|
||||
deck_variant=earthman, significator=sig_card,
|
||||
)
|
||||
char = Character.objects.create(
|
||||
seat=seat, significator=sig_card, confirmed_at=timezone.now()
|
||||
)
|
||||
return room, gamer, seat, char
|
||||
|
||||
|
||||
class PickSeaDealTest(ChannelsFunctionalTest):
|
||||
"""PICK SEA deck stacks, OK btn interaction, card draw, and LOCK HAND."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.browser.set_window_size(800, 1200)
|
||||
Applet.objects.get_or_create(
|
||||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||
)
|
||||
Applet.objects.get_or_create(
|
||||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||
)
|
||||
# Major Arcana sig card
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman, slug="the-schizo-em",
|
||||
defaults={"arcana": "MAJOR", "number": 1, "name": "The Schizo",
|
||||
"levity_qualifier": "Enlightened", "gravity_qualifier": "Engraven"},
|
||||
)
|
||||
_seed_earthman_cards(earthman)
|
||||
self.room, self.gamer, self.seat, self.char = _make_sea_ready_room(earthman)
|
||||
self.room_url = self.live_server_url + reverse(
|
||||
"epic:room", kwargs={"room_id": self.room.id}
|
||||
)
|
||||
|
||||
def _load_sea_overlay(self):
|
||||
"""Navigate to room page and open the sea overlay."""
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.room_url)
|
||||
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn"))
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay"))
|
||||
|
||||
# ── Button presence ───────────────────────────────────────────────── #
|
||||
|
||||
def test_deal_btn_absent(self):
|
||||
"""DEAL btn replaced by deck stacks + LOCK HAND."""
|
||||
self._load_sea_overlay()
|
||||
self.assertEqual(self.browser.find_elements(By.ID, "id_sea_deal"), [])
|
||||
|
||||
def test_lock_hand_btn_present_and_disabled(self):
|
||||
"""LOCK HAND btn is present but disabled before any cards are drawn."""
|
||||
self._load_sea_overlay()
|
||||
lock_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_sea_lock_hand")
|
||||
)
|
||||
self.assertFalse(lock_btn.is_enabled())
|
||||
|
||||
def test_del_btn_present(self):
|
||||
"""DEL btn is always present in the sea overlay."""
|
||||
self._load_sea_overlay()
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_del"))
|
||||
|
||||
# ── Deck stacks ───────────────────────────────────────────────────── #
|
||||
|
||||
def test_two_deck_stacks_present(self):
|
||||
"""Both levity and gravity deck stacks are visible."""
|
||||
self._load_sea_overlay()
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
|
||||
))
|
||||
|
||||
def test_clicking_stack_shows_ok_btn(self):
|
||||
"""Clicking a deck stack reveals its OK btn."""
|
||||
self._load_sea_overlay()
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.assertTrue(ok_btn.is_displayed())
|
||||
|
||||
def test_clicking_elsewhere_hides_ok_btn(self):
|
||||
"""Clicking outside a focused stack dismisses the OK btn."""
|
||||
self._load_sea_overlay()
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
).is_displayed())
|
||||
# Click the sea cards column (not a stack)
|
||||
col = self.browser.find_element(By.CSS_SELECTOR, ".sea-cards-col")
|
||||
self.browser.execute_script("arguments[0].click()", col)
|
||||
self.wait_for(lambda: not any(
|
||||
el.is_displayed()
|
||||
for el in self.browser.find_elements(By.CSS_SELECTOR, ".sea-stack-ok")
|
||||
))
|
||||
|
||||
# ── Card draw ─────────────────────────────────────────────────────── #
|
||||
|
||||
def test_ok_click_fills_cover_position(self):
|
||||
"""First OK click places a card in the Cover position."""
|
||||
self._load_sea_overlay()
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", ok_btn)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
|
||||
))
|
||||
|
||||
def test_lock_hand_enables_after_six_draws(self):
|
||||
"""LOCK HAND btn becomes enabled once all 6 positions are filled."""
|
||||
self._load_sea_overlay()
|
||||
|
||||
for _ in range(6):
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", ok_btn)
|
||||
|
||||
lock_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_sea_lock_hand")
|
||||
)
|
||||
self.assertTrue(lock_btn.is_enabled())
|
||||
|
||||
def test_del_clears_drawn_cards(self):
|
||||
"""DEL btn clears all drawn cards and resets positions to empty."""
|
||||
self._load_sea_overlay()
|
||||
# Draw one card
|
||||
stack = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", stack)
|
||||
ok_btn = self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--levity .sea-stack-ok"
|
||||
))
|
||||
self.browser.execute_script("arguments[0].click()", ok_btn)
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
|
||||
))
|
||||
# DEL clears it
|
||||
del_btn = self.browser.find_element(By.ID, "id_sea_del")
|
||||
self.browser.execute_script("arguments[0].click()", del_btn)
|
||||
self.wait_for(lambda: not self.browser.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot--filled"
|
||||
))
|
||||
299
src/static/tests/SeaDealSpec.js
Normal file
299
src/static/tests/SeaDealSpec.js
Normal file
@@ -0,0 +1,299 @@
|
||||
describe("SeaDeal", () => {
|
||||
let testDiv, overlay, stage, stageCard, statBlock;
|
||||
|
||||
const CARD = {
|
||||
id: 99, name: "Queen of Crowns",
|
||||
arcana: "MIDDLE", suit: "CROWNS", number: 13,
|
||||
corner_rank: "Q", suit_icon: "fa-crown",
|
||||
name_group: "", name_title: "Queen of Crowns",
|
||||
levity_qualifier: "Elevated", gravity_qualifier: "Graven",
|
||||
reversal: "Vacant",
|
||||
keywords_upright: ["nurturing", "practical", "abundance"],
|
||||
keywords_reversed: ["financial dependence", "smothering"],
|
||||
energies: [{ type: "LIBIDO", effect: "Energy entry." }],
|
||||
operations: [{ type: "COVER", effect: "Operation entry." }],
|
||||
};
|
||||
|
||||
function makeFixture({ polarity = "levity" } = {}) {
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<div id="id_sea_overlay" data-sea-user-polarity="${polarity}">
|
||||
|
||||
<!-- Cross grid (simplified) -->
|
||||
<div class="sea-cross">
|
||||
<div class="sea-cross-cell sea-pos-crown">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-past">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-center">
|
||||
<div class="sea-sig-card"></div>
|
||||
<div class="sea-pos-cover">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-pos-cross">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-future">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-root">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sea stage (big card viewer) -->
|
||||
<div class="sea-stage" id="id_sea_stage" style="display:none">
|
||||
<div class="sea-stage-backdrop"></div>
|
||||
<div class="sea-stage-content">
|
||||
<div class="sig-stage-card sea-stage-card" style="--sig-card-w:140px">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<h3 class="fan-card-name"></h3>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
</div>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<div class="fan-card-face-reversal">
|
||||
<p class="fan-card-reversal-name"></p>
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-stat-block sea-stat-block">
|
||||
<button class="btn btn-reverse sea-spin-btn" type="button">SPIN</button>
|
||||
<button class="btn btn-info sea-fyi-btn" type="button">FYI</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
|
||||
</div>
|
||||
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
|
||||
<div class="sig-info-header">
|
||||
<h4 class="sig-info-title"></h4>
|
||||
<p class="sig-info-type"></p>
|
||||
</div>
|
||||
<p class="sig-info-effect"></p>
|
||||
<span class="sig-info-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left sea-fyi-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right sea-fyi-next" type="button">NXT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(testDiv);
|
||||
overlay = testDiv.querySelector("#id_sea_overlay");
|
||||
stage = testDiv.querySelector("#id_sea_stage");
|
||||
stageCard = testDiv.querySelector(".sea-stage-card");
|
||||
statBlock = testDiv.querySelector(".sea-stat-block");
|
||||
SeaDeal._testInit();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (testDiv) testDiv.remove();
|
||||
// Purge any stale overlays left by tests that called makeFixture() twice
|
||||
document.querySelectorAll('#id_sea_overlay').forEach(el => el.remove());
|
||||
});
|
||||
|
||||
// ── openStage ────────────────────────────────────────────────────────── //
|
||||
|
||||
describe("openStage()", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("makes #id_sea_stage visible", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stage.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("populates corner rank on stage card", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank");
|
||||
expect(rank.textContent).toBe("Q");
|
||||
});
|
||||
|
||||
it("shows suit icon on stage card", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const icon = stageCard.querySelector(".fan-card-corner--tl .stage-suit-icon");
|
||||
expect(icon.style.display).not.toBe("none");
|
||||
expect(icon.classList.contains("fa-crown")).toBe(true);
|
||||
});
|
||||
|
||||
it("populates upright card name for non-major", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stageCard.querySelector(".fan-card-name").textContent).toBe("Queen of Crowns");
|
||||
});
|
||||
|
||||
it("puts levity qualifier in qualifier-above for non-major", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
|
||||
});
|
||||
|
||||
it("puts gravity qualifier in qualifier-above for non-major gravity", () => {
|
||||
// Re-init with gravity polarity (no double-append; beforeEach already ran)
|
||||
overlay.dataset.seaUserPolarity = "gravity";
|
||||
SeaDeal._testInit();
|
||||
stageCard = testDiv.querySelector(".sea-stage-card");
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", false);
|
||||
expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
|
||||
});
|
||||
|
||||
it("puts reversal word in reversal-qualifier slot", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Vacant");
|
||||
});
|
||||
|
||||
it("populates upright keywords in stat block", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const items = testDiv.querySelectorAll("#id_sea_stat_upright li");
|
||||
expect(items.length).toBe(3);
|
||||
expect(items[0].textContent).toBe("nurturing");
|
||||
});
|
||||
|
||||
it("populates reversed keywords in stat block", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const items = testDiv.querySelectorAll("#id_sea_stat_reversed li");
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("fills the target position slot", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot");
|
||||
expect(slot.classList.contains("sea-card-slot--filled")).toBe(true);
|
||||
});
|
||||
|
||||
it("resets SPIN to upright when opening a new card", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
stageCard.classList.add("stage-card--reversed");
|
||||
statBlock.classList.add("is-reversed");
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cross", true);
|
||||
expect(stageCard.classList.contains("stage-card--reversed")).toBe(false);
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SPIN in sea stage ─────────────────────────────────────────────────── //
|
||||
|
||||
describe("SPIN btn in sea stage", () => {
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
});
|
||||
|
||||
it("toggles is-reversed on stat block", () => {
|
||||
testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles stage-card--reversed on stage card", () => {
|
||||
testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(stageCard.classList.contains("stage-card--reversed")).toBe(true);
|
||||
});
|
||||
|
||||
it("second SPIN click restores upright", () => {
|
||||
const btn = testDiv.querySelector(".sea-spin-btn");
|
||||
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── FYI in sea stage ──────────────────────────────────────────────────── //
|
||||
|
||||
describe("FYI btn in sea stage", () => {
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
});
|
||||
|
||||
it("FYI click shows the info panel", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector("#id_sea_fyi_panel").style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("shows first energy entry title as 'Energy'", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Energy");
|
||||
});
|
||||
|
||||
it("shows first entry type", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-info-type").textContent).toBe("LIBIDO");
|
||||
});
|
||||
|
||||
it("NXT advances to operation entry", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
testDiv.querySelector(".sea-fyi-next").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Operation");
|
||||
expect(testDiv.querySelector(".sig-info-type").textContent).toBe("COVER");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Backdrop dismiss ──────────────────────────────────────────────────── //
|
||||
|
||||
describe("stage backdrop click", () => {
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
});
|
||||
|
||||
it("hides the sea stage", () => {
|
||||
testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(stage.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("leaves the slot filled after dismiss", () => {
|
||||
testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot");
|
||||
expect(slot.classList.contains("sea-card-slot--filled")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Re-open from deposited slot (two-step tap) ────────────────────────── //
|
||||
|
||||
describe("clicking a deposited slot", () => {
|
||||
let slot;
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
// Dismiss first — adds --visible to slot
|
||||
testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot");
|
||||
});
|
||||
|
||||
it("first click focuses the slot (does not yet open stage)", () => {
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(slot.classList.contains("sea-card-slot--focused")).toBeTrue();
|
||||
expect(stage.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("re-opens the sea stage on second click", () => {
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open
|
||||
expect(stage.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("re-populates stage with the same card rank on second click", () => {
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open
|
||||
const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank");
|
||||
expect(rank.textContent).toBe("Q");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -558,12 +558,12 @@ describe("SigSelect", () => {
|
||||
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated");
|
||||
});
|
||||
|
||||
it("non-major without data-reversal: reversal-name empty, qualifier mirrors polarity", () => {
|
||||
it("non-major without data-reversal: qualifier mirrors polarity, name repeats card title", () => {
|
||||
makeFixture({ polarity: "levity", userRole: "PC" });
|
||||
// fixture default: Minor Arcana, no reversal word
|
||||
hover();
|
||||
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
|
||||
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("");
|
||||
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(card.dataset.nameTitle);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<script src="SeaDealSpec.js"></script>
|
||||
<script src="NatusWheelSpec.js"></script>
|
||||
<script src="NoteSpec.js"></script>
|
||||
<script src="NotePageSpec.js"></script>
|
||||
@@ -32,6 +33,7 @@
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
<script src="/static/apps/epic/sea.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/natus-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
|
||||
@@ -402,6 +402,42 @@
|
||||
|
||||
// DOFF btn
|
||||
&.btn-unequip {
|
||||
color: rgba(var(--priId), 1);
|
||||
border-color: rgba(var(--priId), 1);
|
||||
background-color: rgba(var(--terId), 1);
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--terId), 0.25),
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--terId), 0.12)
|
||||
;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||
0 0 1rem rgba(var(--priId), 1)
|
||||
;
|
||||
box-shadow:
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priId), 0.12)
|
||||
;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 0.18rem solid rgba(var(--priId), 1);
|
||||
text-shadow:
|
||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.12rem rgba(var(--priId), 1)
|
||||
;
|
||||
box-shadow:
|
||||
-0.1rem -0.1rem 0.12rem rgba(var(--terId), 0.25),
|
||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priId), 0.12)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
// FLIP btn
|
||||
&.btn-reveal {
|
||||
color: rgba(var(--priMe), 1);
|
||||
border-color: rgba(var(--priMe), 1);
|
||||
background-color: rgba(var(--terMe), 1);
|
||||
@@ -436,42 +472,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// FLIP btn
|
||||
&.btn-reveal {
|
||||
color: rgba(var(--priCy), 1);
|
||||
border-color: rgba(var(--priCy), 1);
|
||||
background-color: rgba(var(--terCy), 1);
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--terCy), 0.25),
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--terCy), 0.12)
|
||||
;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||
0 0 1rem rgba(var(--priCy), 1)
|
||||
;
|
||||
box-shadow:
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priCy), 0.12)
|
||||
;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 0.18rem solid rgba(var(--priCy), 1);
|
||||
text-shadow:
|
||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.12rem rgba(var(--priCy), 1)
|
||||
;
|
||||
box-shadow:
|
||||
-0.1rem -0.1rem 0.12rem rgba(var(--terCy), 0.25),
|
||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priCy), 0.12)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
// SPIN btn
|
||||
&.btn-reverse {
|
||||
color: rgba(var(--priCy), 1);
|
||||
|
||||
@@ -606,6 +606,14 @@ html:has(.sig-backdrop) {
|
||||
// Polarity qualifier: same colour as the card title in this context
|
||||
.sig-qualifier-above,
|
||||
.sig-qualifier-below { color: rgba(var(--quiUser), 1); }
|
||||
// Upright + reversal title glow — levity
|
||||
.sig-stage-card .fan-card-name,
|
||||
.sig-stage-card .sig-qualifier-above,
|
||||
.sig-stage-card .sig-qualifier-below,
|
||||
.sig-stage-card .fan-card-reversal-name,
|
||||
.sig-stage-card .fan-card-reversal-qualifier {
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.55), 0 0 0.55rem rgba(var(--ninUser), 0.7);
|
||||
}
|
||||
// card-ref spans inside the caution tooltip — must match the base rule's
|
||||
// .sig-stat-block .sig-info-effect .card-ref specificity (0,3,0) to win.
|
||||
.sig-info-effect .card-ref { color: rgba(var(--quiUser), 1); }
|
||||
@@ -624,6 +632,14 @@ html:has(.sig-backdrop) {
|
||||
// Polarity qualifier: terUser for gravity (quiUser is levity's equivalent)
|
||||
.sig-qualifier-above,
|
||||
.sig-qualifier-below { color: rgba(var(--terUser), 1); }
|
||||
// Upright + reversal title glow — gravity
|
||||
.sig-stage-card .fan-card-name,
|
||||
.sig-stage-card .sig-qualifier-above,
|
||||
.sig-stage-card .sig-qualifier-below,
|
||||
.sig-stage-card .fan-card-reversal-name,
|
||||
.sig-stage-card .fan-card-reversal-qualifier {
|
||||
text-shadow: 1px 1px 0 rgba(0,0,0,1), 0 0 0.25rem rgba(var(--ninUser), 0.25);
|
||||
}
|
||||
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
||||
}
|
||||
|
||||
@@ -785,7 +801,9 @@ $sea-card-h: 6.5rem;
|
||||
.sea-card-slot {
|
||||
width: $sea-card-w;
|
||||
height: $sea-card-h;
|
||||
border: 0.15rem dashed rgba(var(--terUser), 0.45);
|
||||
background-color: rgba(var(--duoUser), 1);
|
||||
border: 0.15rem dashed rgba(var(--terUser), 1);
|
||||
box-shadow: 0 0 2px rgba(var(--priUser), 0.5);
|
||||
border-radius: 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -795,8 +813,92 @@ $sea-card-h: 6.5rem;
|
||||
}
|
||||
|
||||
.sea-card-slot--crossing {
|
||||
width: $sea-card-h; // rotated — swap w/h
|
||||
height: $sea-card-w;
|
||||
// Keep portrait dimensions; rotate(90deg) in .sea-pos-cross supplies the landscape visual.
|
||||
// Swapping w/h here caused flex-shrink to squish the longer edge to fit the 4rem container.
|
||||
width: $sea-card-w;
|
||||
height: $sea-card-h;
|
||||
}
|
||||
|
||||
.sea-card-slot--filled {
|
||||
// Start invisible; transition to .sea-card-slot--visible on deposit
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease;
|
||||
border: 0.15rem solid transparent;
|
||||
border-radius: 0.3rem;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
|
||||
.fan-corner-rank { font-size: 1.15rem; font-weight: 700; line-height: 1; }
|
||||
i { font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
// Levity drawn card — secUser bg, priUser text + border (matches stage card polarity)
|
||||
.sea-card-slot--filled.sea-card-slot--levity {
|
||||
color: rgba(var(--priUser), 0.9);
|
||||
background: rgba(var(--secUser), 0.85);
|
||||
border-color: rgba(var(--priUser), 1);
|
||||
}
|
||||
// Gravity drawn card — priUser bg, secUser text + border
|
||||
.sea-card-slot--filled.sea-card-slot--gravity {
|
||||
color: rgba(var(--secUser), 0.9);
|
||||
background: rgba(var(--priUser), 0.85);
|
||||
border-color: rgba(var(--secUser), 0.6);
|
||||
}
|
||||
|
||||
// Deposited — fully opaque by default; Cover/Cross are semi-transparent
|
||||
.sea-card-slot--visible { opacity: 1; transition: opacity 1s ease; }
|
||||
|
||||
.sea-pos-cover .sea-card-slot--visible { opacity: 0.3; }
|
||||
.sea-pos-cross .sea-card-slot--visible { opacity: 0.15; }
|
||||
|
||||
// Hover: reveal fully (snappy)
|
||||
.sea-pos-cover .sea-card-slot--visible:hover,
|
||||
.sea-pos-cross .sea-card-slot--visible:hover { opacity: 1; transition: opacity 0.15s ease; }
|
||||
|
||||
// Focused (first tap): persist at opacity 1 until clicked outside
|
||||
.sea-card-slot--focused { opacity: 1 !important; transition: opacity 0.15s ease; }
|
||||
|
||||
// Cover + Cross — absolutely overlaid on the Sig card in .sea-pos-center
|
||||
.sea-pos-center { position: relative; }
|
||||
|
||||
.sea-pos-cover,
|
||||
.sea-pos-cross {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
.sea-card-slot { pointer-events: auto; }
|
||||
}
|
||||
|
||||
.sea-pos-cover { z-index: 3; } // above sig (z-index: 2)
|
||||
.sea-pos-cross { z-index: 4; } // above cover
|
||||
// Empty Cover/Cross slots are invisible — they reveal only once a card is deposited
|
||||
.sea-pos-cover .sea-card-slot--empty,
|
||||
.sea-pos-cross .sea-card-slot--empty { opacity: 0; pointer-events: none; }
|
||||
|
||||
.sea-pos-cross .sea-card-slot { transform: rotate(90deg); }
|
||||
|
||||
// Sig card in center slot — compact rank + icon display; tilted CCW so Cover slot peeks through
|
||||
.sea-sig-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
transform: rotate(-5deg);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
.fan-corner-rank {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: rgba(var(--secUser), 0.85);
|
||||
}
|
||||
i { font-size: 1rem; color: rgba(var(--secUser), 0.75); }
|
||||
}
|
||||
|
||||
// .sig-stage-card is normally scoped inside .sig-stage — re-apply the card shell
|
||||
@@ -867,18 +969,289 @@ $sea-card-h: 6.5rem;
|
||||
option { background: rgba(var(--priUser), 1); }
|
||||
}
|
||||
|
||||
.sea-form-col > #id_sea_deal {
|
||||
// Deck stacks — DECKS label + gravity + levity piles
|
||||
.sea-stacks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.sea-stacks-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
text-transform: uppercase;
|
||||
// Fill the full card height ($sea-card-h: 6.5rem) with 5 letters
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.32em;
|
||||
font-weight: 700;
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.sea-deck-stack {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sea-stack-face {
|
||||
position: relative;
|
||||
width: $sea-card-w;
|
||||
height: $sea-card-h;
|
||||
border-radius: 0.3rem;
|
||||
border: 0.15rem solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: box-shadow 0.15s;
|
||||
z-index: 1; // sits above the name label
|
||||
}
|
||||
|
||||
.sea-stack-ok {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
transform: translateY(-50%);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.sea-deck-stack { gap: 0; } // remove gap so name slides under the face
|
||||
|
||||
.sea-stack-name {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
opacity: 0.6;
|
||||
// Pull top of label partially under the stack face
|
||||
// margin-top: -0.1rem;
|
||||
transform: scaleY(1.2);
|
||||
transform-origin: top center;
|
||||
z-index: 0;
|
||||
}
|
||||
.sea-deck-stack--gravity .sea-stack-name { color: rgba(var(--quaUser), 1); }
|
||||
.sea-deck-stack--levity .sea-stack-name { color: rgba(var(--terUser), 1); }
|
||||
|
||||
// Deck backs — face-down pile colour identifies polarity
|
||||
$_sea-shadow: 1px 2px 0 rgba(0,0,0,0.7), 0 4px 0 rgba(0,0,0,0.18), 2px 5px 5px rgba(0,0,0,0.5);
|
||||
$_glow-levity: 0 0 0.8rem 0.15rem rgba(var(--ninUser), 0.6);
|
||||
$_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
|
||||
|
||||
.sea-deck-stack--levity .sea-stack-face {
|
||||
background: rgba(var(--terUser), 0.88);
|
||||
border-color: rgba(var(--ninUser), 0.65);
|
||||
box-shadow: $_sea-shadow;
|
||||
}
|
||||
.sea-deck-stack--gravity .sea-stack-face {
|
||||
background: rgba(var(--quiUser), 0.88);
|
||||
border-color: rgba(var(--quaUser), 0.65);
|
||||
box-shadow: $_sea-shadow;
|
||||
}
|
||||
|
||||
// Glow on hover, :active, and while OK is showing (--active class set by JS)
|
||||
.sea-deck-stack--levity:hover .sea-stack-face,
|
||||
.sea-deck-stack--levity:active .sea-stack-face,
|
||||
.sea-deck-stack--levity.sea-deck-stack--active .sea-stack-face { box-shadow: $_sea-shadow, $_glow-levity; }
|
||||
.sea-deck-stack--gravity:hover .sea-stack-face,
|
||||
.sea-deck-stack--gravity:active .sea-stack-face,
|
||||
.sea-deck-stack--gravity.sea-deck-stack--active .sea-stack-face { box-shadow: $_sea-shadow, $_glow-gravity; }
|
||||
|
||||
// Form action row — LOCK HAND + DEL side by side at the bottom
|
||||
.sea-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.75rem;
|
||||
|
||||
}
|
||||
|
||||
// NVM button — same positioning as .natus-modal-wrap > .btn-cancel
|
||||
.sea-modal-wrap > .btn-cancel {
|
||||
position: absolute;
|
||||
top: -0.75rem;
|
||||
right: -0.75rem;
|
||||
top: -1rem;
|
||||
right: -1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// ── Sea stage — big card viewer ───────────────────────────────────────────────
|
||||
|
||||
.sea-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sea-stage-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.sea-stage-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
// Stage card — size matches sig-select stage (--sig-card-w driven by inline style)
|
||||
.sea-stage-card {
|
||||
flex-shrink: 0;
|
||||
width: var(--sig-card-w, 140px);
|
||||
height: auto;
|
||||
aspect-ratio: 5 / 8;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
border: 0.15rem solid rgba(var(--secUser), 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 0.25rem;
|
||||
overflow: hidden;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.4s ease;
|
||||
|
||||
// Flip-in animation when stage opens
|
||||
&--shown {
|
||||
animation: sea-flip-in 0.35s ease forwards;
|
||||
}
|
||||
|
||||
.fan-card-corner--tl,
|
||||
.fan-card-corner--br {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
gap: 0.1rem;
|
||||
.fan-corner-rank { font-size: calc(var(--sig-card-w, 140px) * 0.133); font-weight: 700; }
|
||||
i { font-size: calc(var(--sig-card-w, 140px) * 0.1); }
|
||||
}
|
||||
|
||||
.fan-card-face {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0.15rem;
|
||||
gap: 0.2rem;
|
||||
|
||||
.fan-card-face-upright { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
|
||||
.fan-card-face-reversal { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding-top: 0.1rem; }
|
||||
.fan-card-name-group { font-size: calc(var(--sig-card-w, 140px) * 0.073); opacity: 0.6; }
|
||||
.sig-qualifier-above,
|
||||
.sig-qualifier-below,
|
||||
.fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
|
||||
.fan-card-name,
|
||||
.fan-card-reversal-name { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
|
||||
.fan-card-arcana { font-size: calc(var(--sig-card-w, 140px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
|
||||
.fan-card-reversal-qualifier,
|
||||
.fan-card-reversal-name { transform: rotate(180deg); opacity: 0.25; }
|
||||
}
|
||||
|
||||
&.stage-card--reversed {
|
||||
transform: rotate(180deg);
|
||||
.fan-card-reversal-qualifier,
|
||||
.fan-card-reversal-name { opacity: 1; }
|
||||
.fan-card-name,
|
||||
.sig-qualifier-above,
|
||||
.sig-qualifier-below { opacity: 0.25; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sea-flip-in {
|
||||
0% { transform: perspective(600px) rotateY(-90deg) scale(0.4); opacity: 0; }
|
||||
60% { transform: perspective(600px) rotateY(8deg) scale(1.03); opacity: 1; }
|
||||
100% { transform: perspective(600px) rotateY(0deg) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
// Sea stage card title — polarity-specific colour + shared glow
|
||||
$_sea-title-shadow: 1px 1px 0 rgba(0,0,0,1), 0 0 0.25rem rgba(var(--ninUser), 0.25);
|
||||
$_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .fan-card-reversal-name, .fan-card-reversal-qualifier';
|
||||
|
||||
.sea-stage--levity .sea-stage-card {
|
||||
background: rgba(var(--secUser), 1);
|
||||
border-color: rgba(var(--priUser), 1);
|
||||
color: rgba(var(--priUser), 1);
|
||||
.fan-card-arcana,
|
||||
.fan-card-corner {
|
||||
color: rgba(var(--priUser), 1);
|
||||
}
|
||||
.fan-card-name, .sig-qualifier-above, .sig-qualifier-below,
|
||||
.fan-card-reversal-name, .fan-card-reversal-qualifier {
|
||||
color: rgba(var(--quiUser), 1);
|
||||
text-shadow: $_sea-title-shadow;
|
||||
}
|
||||
}
|
||||
.sea-stage--gravity .sea-stage-card {
|
||||
.fan-card-name, .sig-qualifier-above, .sig-qualifier-below,
|
||||
.fan-card-reversal-name, .fan-card-reversal-qualifier {
|
||||
color: rgba(var(--terUser), 1);
|
||||
text-shadow: $_sea-title-shadow;
|
||||
}
|
||||
}
|
||||
|
||||
// Sea stat block — reuses sig-select stat-block sizing, scoped to sea-stage
|
||||
.sea-stage-content .sea-stat-block {
|
||||
flex: 0 0 auto;
|
||||
width: var(--sig-card-w, 140px);
|
||||
height: calc(var(--sig-card-w, 140px) * 8 / 5);
|
||||
background: rgba(var(--priUser), 0.85);
|
||||
border-radius: 0.4rem;
|
||||
border: 0.1rem solid rgba(var(--terUser), 0.15);
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
.sea-spin-btn { position: absolute; top: -1rem; right: -1rem; margin: 0; z-index: 50; }
|
||||
.sea-fyi-btn { position: absolute; top: 1.25rem; right: -1rem; margin: 0; z-index: 50; }
|
||||
|
||||
.stat-face { display: none; padding: calc(var(--sig-card-w, 140px) * 0.37) calc(var(--sig-card-w, 140px) * 0.1) calc(var(--sig-card-w, 140px) * 0.08); }
|
||||
.stat-face--upright { display: block; }
|
||||
&.is-reversed { .stat-face--upright { display: none; } .stat-face--reversed { display: block; } }
|
||||
|
||||
.stat-face-label { font-size: calc(var(--sig-card-w, 140px) * 0.063); text-transform: uppercase; letter-spacing: 0.09em; opacity: 0.4; margin: 0 0 calc(var(--sig-card-w, 140px) * 0.07); }
|
||||
.stat-keywords { list-style: none; padding: 0; margin: 0;
|
||||
li { font-size: calc(var(--sig-card-w, 140px) * 0.083); padding: calc(var(--sig-card-w, 140px) * 0.042) 0; opacity: 0.85; border-bottom: 0.05rem solid rgba(var(--terUser), 0.12); &:last-child { border-bottom: none; } }
|
||||
}
|
||||
|
||||
.sig-info {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background-color: rgba(var(--tooltip-bg), 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
border-radius: 0.4rem;
|
||||
border: 0.1rem solid rgba(var(--priYl), 0.35);
|
||||
padding: 0.75rem;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
overflow-y: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sea-fyi-prev,
|
||||
.sea-fyi-next { display: inline-flex; position: absolute; bottom: -1rem; margin: 0; z-index: 70; }
|
||||
.sea-fyi-prev { left: -1rem; }
|
||||
.sea-fyi-next { right: -1rem; }
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
html.sea-open body .container .navbar,
|
||||
html.sea-open body #id_footer {
|
||||
|
||||
@@ -57,7 +57,8 @@ html:has(.gate-backdrop) {
|
||||
|
||||
html:has(.gate-backdrop) #id_aperture_fill,
|
||||
html:has(.sig-backdrop) #id_aperture_fill,
|
||||
html:has(.role-select-backdrop) #id_aperture_fill {
|
||||
html:has(.role-select-backdrop) #id_aperture_fill,
|
||||
html.sea-open #id_aperture_fill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
299
src/static_src/tests/SeaDealSpec.js
Normal file
299
src/static_src/tests/SeaDealSpec.js
Normal file
@@ -0,0 +1,299 @@
|
||||
describe("SeaDeal", () => {
|
||||
let testDiv, overlay, stage, stageCard, statBlock;
|
||||
|
||||
const CARD = {
|
||||
id: 99, name: "Queen of Crowns",
|
||||
arcana: "MIDDLE", suit: "CROWNS", number: 13,
|
||||
corner_rank: "Q", suit_icon: "fa-crown",
|
||||
name_group: "", name_title: "Queen of Crowns",
|
||||
levity_qualifier: "Elevated", gravity_qualifier: "Graven",
|
||||
reversal: "Vacant",
|
||||
keywords_upright: ["nurturing", "practical", "abundance"],
|
||||
keywords_reversed: ["financial dependence", "smothering"],
|
||||
energies: [{ type: "LIBIDO", effect: "Energy entry." }],
|
||||
operations: [{ type: "COVER", effect: "Operation entry." }],
|
||||
};
|
||||
|
||||
function makeFixture({ polarity = "levity" } = {}) {
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<div id="id_sea_overlay" data-sea-user-polarity="${polarity}">
|
||||
|
||||
<!-- Cross grid (simplified) -->
|
||||
<div class="sea-cross">
|
||||
<div class="sea-cross-cell sea-pos-crown">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-past">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-center">
|
||||
<div class="sea-sig-card"></div>
|
||||
<div class="sea-pos-cover">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-pos-cross">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-future">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
<div class="sea-cross-cell sea-pos-root">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sea stage (big card viewer) -->
|
||||
<div class="sea-stage" id="id_sea_stage" style="display:none">
|
||||
<div class="sea-stage-backdrop"></div>
|
||||
<div class="sea-stage-content">
|
||||
<div class="sig-stage-card sea-stage-card" style="--sig-card-w:140px">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<h3 class="fan-card-name"></h3>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
</div>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<div class="fan-card-face-reversal">
|
||||
<p class="fan-card-reversal-name"></p>
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-stat-block sea-stat-block">
|
||||
<button class="btn btn-reverse sea-spin-btn" type="button">SPIN</button>
|
||||
<button class="btn btn-info sea-fyi-btn" type="button">FYI</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
|
||||
</div>
|
||||
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
|
||||
<div class="sig-info-header">
|
||||
<h4 class="sig-info-title"></h4>
|
||||
<p class="sig-info-type"></p>
|
||||
</div>
|
||||
<p class="sig-info-effect"></p>
|
||||
<span class="sig-info-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left sea-fyi-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right sea-fyi-next" type="button">NXT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(testDiv);
|
||||
overlay = testDiv.querySelector("#id_sea_overlay");
|
||||
stage = testDiv.querySelector("#id_sea_stage");
|
||||
stageCard = testDiv.querySelector(".sea-stage-card");
|
||||
statBlock = testDiv.querySelector(".sea-stat-block");
|
||||
SeaDeal._testInit();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (testDiv) testDiv.remove();
|
||||
// Purge any stale overlays left by tests that called makeFixture() twice
|
||||
document.querySelectorAll('#id_sea_overlay').forEach(el => el.remove());
|
||||
});
|
||||
|
||||
// ── openStage ────────────────────────────────────────────────────────── //
|
||||
|
||||
describe("openStage()", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("makes #id_sea_stage visible", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stage.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("populates corner rank on stage card", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank");
|
||||
expect(rank.textContent).toBe("Q");
|
||||
});
|
||||
|
||||
it("shows suit icon on stage card", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const icon = stageCard.querySelector(".fan-card-corner--tl .stage-suit-icon");
|
||||
expect(icon.style.display).not.toBe("none");
|
||||
expect(icon.classList.contains("fa-crown")).toBe(true);
|
||||
});
|
||||
|
||||
it("populates upright card name for non-major", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stageCard.querySelector(".fan-card-name").textContent).toBe("Queen of Crowns");
|
||||
});
|
||||
|
||||
it("puts levity qualifier in qualifier-above for non-major", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
|
||||
});
|
||||
|
||||
it("puts gravity qualifier in qualifier-above for non-major gravity", () => {
|
||||
// Re-init with gravity polarity (no double-append; beforeEach already ran)
|
||||
overlay.dataset.seaUserPolarity = "gravity";
|
||||
SeaDeal._testInit();
|
||||
stageCard = testDiv.querySelector(".sea-stage-card");
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", false);
|
||||
expect(stageCard.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
|
||||
});
|
||||
|
||||
it("puts reversal word in reversal-qualifier slot", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Vacant");
|
||||
});
|
||||
|
||||
it("populates upright keywords in stat block", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const items = testDiv.querySelectorAll("#id_sea_stat_upright li");
|
||||
expect(items.length).toBe(3);
|
||||
expect(items[0].textContent).toBe("nurturing");
|
||||
});
|
||||
|
||||
it("populates reversed keywords in stat block", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const items = testDiv.querySelectorAll("#id_sea_stat_reversed li");
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("fills the target position slot", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot");
|
||||
expect(slot.classList.contains("sea-card-slot--filled")).toBe(true);
|
||||
});
|
||||
|
||||
it("resets SPIN to upright when opening a new card", () => {
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
stageCard.classList.add("stage-card--reversed");
|
||||
statBlock.classList.add("is-reversed");
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cross", true);
|
||||
expect(stageCard.classList.contains("stage-card--reversed")).toBe(false);
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SPIN in sea stage ─────────────────────────────────────────────────── //
|
||||
|
||||
describe("SPIN btn in sea stage", () => {
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
});
|
||||
|
||||
it("toggles is-reversed on stat block", () => {
|
||||
testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles stage-card--reversed on stage card", () => {
|
||||
testDiv.querySelector(".sea-spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(stageCard.classList.contains("stage-card--reversed")).toBe(true);
|
||||
});
|
||||
|
||||
it("second SPIN click restores upright", () => {
|
||||
const btn = testDiv.querySelector(".sea-spin-btn");
|
||||
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── FYI in sea stage ──────────────────────────────────────────────────── //
|
||||
|
||||
describe("FYI btn in sea stage", () => {
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
});
|
||||
|
||||
it("FYI click shows the info panel", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector("#id_sea_fyi_panel").style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("shows first energy entry title as 'Energy'", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Energy");
|
||||
});
|
||||
|
||||
it("shows first entry type", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-info-type").textContent).toBe("LIBIDO");
|
||||
});
|
||||
|
||||
it("NXT advances to operation entry", () => {
|
||||
testDiv.querySelector(".sea-fyi-btn").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
testDiv.querySelector(".sea-fyi-next").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-info-title").textContent).toBe("Operation");
|
||||
expect(testDiv.querySelector(".sig-info-type").textContent).toBe("COVER");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Backdrop dismiss ──────────────────────────────────────────────────── //
|
||||
|
||||
describe("stage backdrop click", () => {
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
});
|
||||
|
||||
it("hides the sea stage", () => {
|
||||
testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(stage.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("leaves the slot filled after dismiss", () => {
|
||||
testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
const slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot");
|
||||
expect(slot.classList.contains("sea-card-slot--filled")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Re-open from deposited slot (two-step tap) ────────────────────────── //
|
||||
|
||||
describe("clicking a deposited slot", () => {
|
||||
let slot;
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
SeaDeal.openStage(CARD, ".sea-pos-cover", true);
|
||||
// Dismiss first — adds --visible to slot
|
||||
testDiv.querySelector(".sea-stage-backdrop").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
slot = testDiv.querySelector(".sea-pos-cover .sea-card-slot");
|
||||
});
|
||||
|
||||
it("first click focuses the slot (does not yet open stage)", () => {
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(slot.classList.contains("sea-card-slot--focused")).toBeTrue();
|
||||
expect(stage.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("re-opens the sea stage on second click", () => {
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open
|
||||
expect(stage.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("re-populates stage with the same card rank on second click", () => {
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // focus
|
||||
slot.dispatchEvent(new MouseEvent("click", { bubbles: true })); // open
|
||||
const rank = stageCard.querySelector(".fan-card-corner--tl .fan-corner-rank");
|
||||
expect(rank.textContent).toBe("Q");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -558,12 +558,12 @@ describe("SigSelect", () => {
|
||||
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated");
|
||||
});
|
||||
|
||||
it("non-major without data-reversal: reversal-name empty, qualifier mirrors polarity", () => {
|
||||
it("non-major without data-reversal: qualifier mirrors polarity, name repeats card title", () => {
|
||||
makeFixture({ polarity: "levity", userRole: "PC" });
|
||||
// fixture default: Minor Arcana, no reversal word
|
||||
hover();
|
||||
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Elevated");
|
||||
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("");
|
||||
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe(card.dataset.nameTitle);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<script src="SeaDealSpec.js"></script>
|
||||
<script src="NatusWheelSpec.js"></script>
|
||||
<script src="NoteSpec.js"></script>
|
||||
<script src="NotePageSpec.js"></script>
|
||||
@@ -32,6 +33,7 @@
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
<script src="/static/apps/epic/sea.js"></script>
|
||||
<script src="/static/apps/gameboard/d3.min.js"></script>
|
||||
<script src="/static/apps/gameboard/natus-wheel.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
<div class="natus-overlay"
|
||||
id="id_natus_overlay"
|
||||
data-preview-url="{% url 'epic:natus_preview' room.id %}"
|
||||
data-save-url="{% url 'epic:natus_save' room.id %}">
|
||||
data-save-url="{% url 'epic:natus_save' room.id %}"
|
||||
data-sea-partial-url="{% url 'epic:sea_partial' room.id %}"
|
||||
data-user-seat-role="{{ user_seat_role }}">
|
||||
|
||||
<div class="natus-modal-wrap">
|
||||
<div class="natus-modal">
|
||||
@@ -369,9 +371,11 @@
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
.then(data => {
|
||||
if (!data.confirmed) {
|
||||
setStatus('Sky saved!');
|
||||
setTimeout(closeNatus, 1200);
|
||||
}
|
||||
// Confirmed state is driven by the room:sky_confirmed WS event
|
||||
})
|
||||
.catch(err => {
|
||||
setStatus(`Save failed: ${err.message}`, 'error');
|
||||
@@ -379,6 +383,28 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sky confirmed → inject sea partial ───────────────────────────────────
|
||||
|
||||
const SEA_PARTIAL_URL = overlay.dataset.seaPartialUrl;
|
||||
|
||||
function _onSkyConfirmed() {
|
||||
fetch(SEA_PARTIAL_URL, { credentials: 'same-origin' })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
// Remove natus overlay + backdrop; inject sea partial before body close
|
||||
var backdrop = document.querySelector('.natus-backdrop');
|
||||
if (backdrop) backdrop.remove();
|
||||
overlay.remove();
|
||||
document.documentElement.classList.remove('natus-open');
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
document.documentElement.classList.add('sea-open');
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: just close natus and let page refresh handle the transition
|
||||
closeNatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ── CSRF ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _getCsrf() {
|
||||
@@ -391,6 +417,15 @@
|
||||
// openNatus() so the animation plays when the modal opens, not silently
|
||||
// in the background on page load.
|
||||
|
||||
// WS: server broadcasts sky_confirmed when any gamer confirms their sky.
|
||||
// Only act when the event's seat_role matches this browser's seat.
|
||||
const MY_SEAT_ROLE = overlay.dataset.userSeatRole;
|
||||
|
||||
window.addEventListener('room:sky_confirmed', function (e) {
|
||||
if (MY_SEAT_ROLE && e.detail.seat_role && e.detail.seat_role !== MY_SEAT_ROLE) return;
|
||||
_onSkyConfirmed();
|
||||
});
|
||||
|
||||
_restoreForm();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
{# Layout is the reverse of PICK SKY: cards left (transparent), form right #}
|
||||
|
||||
<div class="sea-backdrop"></div>
|
||||
<div class="sea-overlay" id="id_sea_overlay">
|
||||
<div class="sea-overlay" id="id_sea_overlay"
|
||||
data-sea-deck-url="{% url 'epic:sea_deck' room.id %}"
|
||||
data-sea-user-polarity="{{ user_polarity }}">
|
||||
|
||||
<div class="sea-modal-wrap">
|
||||
<div class="sea-modal">
|
||||
@@ -19,39 +21,39 @@
|
||||
{# ── Cards column (transparent) ───────────────────────────── #}
|
||||
<div class="sea-cards-col">
|
||||
<div class="sea-cross">
|
||||
{# Crown — position 3 #}
|
||||
{# Crown — CC pos 3 / EV pos 5 #}
|
||||
<div class="sea-cross-cell sea-pos-crown">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
{# Past — position 4 #}
|
||||
{# Beneath (past) — CC pos 4 / EV pos 3 #}
|
||||
<div class="sea-cross-cell sea-pos-past">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
{# Center — Significator (already placed) #}
|
||||
{# Center — Significator (always placed) + Cover + Cross overlaid #}
|
||||
<div class="sea-cross-cell sea-pos-center">
|
||||
<div class="sig-stage-card" style="--sig-card-w: 4rem">
|
||||
<div class="sig-stage-card sea-sig-card" style="--sig-card-w: 4rem">
|
||||
{% if my_tray_sig %}
|
||||
<div class="fan-card-face">
|
||||
{% if my_tray_sig.arcana == "MAJOR" %}
|
||||
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
|
||||
<p class="sig-qualifier-below">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
|
||||
{% else %}
|
||||
<p class="sig-qualifier-above">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
|
||||
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
|
||||
<span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>
|
||||
{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Cover — CC/EV pos 1, stacked face-up on Sig #}
|
||||
<div class="sea-pos-cover">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
{# Cross — CC/EV pos 2, rotated 90° on Cover #}
|
||||
<div class="sea-pos-cross">
|
||||
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing"></div>
|
||||
</div>
|
||||
</div>
|
||||
{# Future — position 5 #}
|
||||
{# Before (future) — CC pos 5 / EV pos 6 #}
|
||||
<div class="sea-cross-cell sea-pos-future">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
{# Root — position 1 #}
|
||||
{# Behind (root) — CC pos 6 / EV pos 4 #}
|
||||
<div class="sea-cross-cell sea-pos-root">
|
||||
<div class="sea-card-slot sea-card-slot--empty"></div>
|
||||
</div>
|
||||
{# Crossing — position 2 (rotated) deferred; re-add once layout is finalized #}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,11 +73,33 @@
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Two face-down deck piles — tap to proffer OK #}
|
||||
<div class="sea-stacks">
|
||||
<span class="sea-stacks-label">DECKS</span>
|
||||
<div class="sea-deck-stack sea-deck-stack--gravity">
|
||||
<div class="sea-stack-face">
|
||||
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
|
||||
</div>
|
||||
<span class="sea-stack-name">Gravity</span>
|
||||
</div>
|
||||
<div class="sea-deck-stack sea-deck-stack--levity">
|
||||
<div class="sea-stack-face">
|
||||
<button class="btn btn-reveal sea-stack-ok" type="button">FLIP</button>
|
||||
</div>
|
||||
<span class="sea-stack-name">Levity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="id_sea_deal" class="btn btn-primary" disabled>
|
||||
Deal
|
||||
<div class="sea-form-actions">
|
||||
<button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled>
|
||||
LOCK HAND
|
||||
</button>
|
||||
<button type="button" id="id_sea_del" class="btn btn-danger">
|
||||
DEL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -86,6 +110,59 @@
|
||||
<button type="button" id="id_sea_cancel" class="btn btn-cancel btn-sm">NVM</button>
|
||||
|
||||
</div>{# /.sea-modal-wrap #}
|
||||
|
||||
{# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
|
||||
<div class="sea-stage" id="id_sea_stage" style="display:none">
|
||||
<div class="sea-stage-backdrop"></div>
|
||||
<div class="sea-stage-content">
|
||||
<div class="sig-stage-card sea-stage-card" style="--sig-card-w:140px">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<h3 class="fan-card-name"></h3>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
</div>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<div class="fan-card-face-reversal">
|
||||
<p class="fan-card-reversal-name"></p>
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-stat-block sea-stat-block">
|
||||
<button class="btn btn-reverse sea-spin-btn" type="button">SPIN</button>
|
||||
<button class="btn btn-info sea-fyi-btn" type="button">FYI</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
|
||||
</div>
|
||||
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
|
||||
<div class="sig-info-header">
|
||||
<h4 class="sig-info-title"></h4>
|
||||
<p class="sig-info-type"></p>
|
||||
</div>
|
||||
<p class="sig-info-effect"></p>
|
||||
<span class="sig-info-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left sea-fyi-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right sea-fyi-next" type="button">NXT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{# /.sea-overlay #}
|
||||
|
||||
<script>
|
||||
@@ -106,6 +183,100 @@
|
||||
const pickSeaBtn = document.getElementById('id_pick_sea_btn');
|
||||
if (pickSeaBtn) pickSeaBtn.addEventListener('click', openSea);
|
||||
cancelBtn.addEventListener('click', closeSea);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSea(); });
|
||||
|
||||
// ── Deck draw ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SEA_DECK_URL = overlay.dataset.seaDeckUrl;
|
||||
|
||||
const SPREAD_ORDER = {
|
||||
'waite-smith': ['.sea-pos-cover', '.sea-pos-cross', '.sea-pos-crown', '.sea-pos-root', '.sea-pos-future', '.sea-pos-past'],
|
||||
'escape-velocity': ['.sea-pos-cover', '.sea-pos-cross', '.sea-pos-root', '.sea-pos-past', '.sea-pos-crown', '.sea-pos-future'],
|
||||
};
|
||||
|
||||
let levityPile = [], gravityPile = [];
|
||||
let _filled = 0;
|
||||
let _activeStack = null;
|
||||
|
||||
const spreadSel = overlay.querySelector('#id_sea_spread');
|
||||
const lockBtn = overlay.querySelector('#id_sea_lock_hand');
|
||||
const delBtn = overlay.querySelector('#id_sea_del');
|
||||
|
||||
function _spreadKey() {
|
||||
return spreadSel ? spreadSel.value : 'waite-smith';
|
||||
}
|
||||
|
||||
function _nextPosSelector() {
|
||||
const order = SPREAD_ORDER[_spreadKey()] || SPREAD_ORDER['waite-smith'];
|
||||
return order[_filled] || null;
|
||||
}
|
||||
|
||||
function _hideOk() {
|
||||
if (_activeStack) {
|
||||
const ok = _activeStack.querySelector('.sea-stack-ok');
|
||||
if (ok) ok.style.display = 'none';
|
||||
_activeStack.classList.remove('sea-deck-stack--active');
|
||||
_activeStack = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _showOk(stack) {
|
||||
_hideOk();
|
||||
_activeStack = stack;
|
||||
stack.classList.add('sea-deck-stack--active');
|
||||
const ok = stack.querySelector('.sea-stack-ok');
|
||||
if (ok) ok.style.display = '';
|
||||
}
|
||||
|
||||
function _reset() {
|
||||
_filled = 0;
|
||||
_hideOk();
|
||||
overlay.querySelectorAll('.sea-card-slot').forEach(s => {
|
||||
s.classList.remove('sea-card-slot--filled', 'sea-card-slot--visible', 'sea-card-slot--focused', 'sea-card-slot--levity', 'sea-card-slot--gravity');
|
||||
s.classList.add('sea-card-slot--empty');
|
||||
s.innerHTML = '';
|
||||
delete s.dataset.cardId;
|
||||
delete s.dataset.posKey;
|
||||
});
|
||||
if (lockBtn) lockBtn.disabled = true;
|
||||
if (window.SeaDeal) SeaDeal.resetHand();
|
||||
_fetchDeck();
|
||||
}
|
||||
|
||||
function _fetchDeck() {
|
||||
fetch(SEA_DECK_URL, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => { levityPile = data.levity || []; gravityPile = data.gravity || []; })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
overlay.querySelectorAll('.sea-deck-stack').forEach(stack => {
|
||||
stack.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
_activeStack === stack ? _hideOk() : _showOk(stack);
|
||||
});
|
||||
const ok = stack.querySelector('.sea-stack-ok');
|
||||
if (ok) {
|
||||
ok.style.display = 'none';
|
||||
ok.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const isLevity = stack.classList.contains('sea-deck-stack--levity');
|
||||
const pile = isLevity ? levityPile : gravityPile;
|
||||
const card = pile.length ? pile.shift() : null;
|
||||
const pos = _nextPosSelector();
|
||||
if (card && pos) {
|
||||
_filled++;
|
||||
if (lockBtn) lockBtn.disabled = (_filled < 6);
|
||||
if (window.SeaDeal) SeaDeal.openStage(card, pos, isLevity);
|
||||
}
|
||||
_hideOk();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', _hideOk);
|
||||
if (delBtn) delBtn.addEventListener('click', _reset);
|
||||
|
||||
_fetchDeck();
|
||||
if (window.SeaDeal) SeaDeal.reinit();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -113,5 +113,6 @@
|
||||
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/role-select.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/sig-select.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/tray.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
|
||||
Reference in New Issue
Block a user