diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 639331e..e7cccc0 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -185,6 +185,11 @@ class DeckVariant(models.Model): description = models.TextField(blank=True) is_default = models.BooleanField(default=False) + @property + def short_key(self): + """First dash-separated word of slug — used as an HTML id component.""" + return self.slug.split('-')[0] + def __str__(self): return f"{self.name} ({self.card_count} cards)" diff --git a/src/apps/gameboard/static/apps/gameboard/gameboard.js b/src/apps/gameboard/static/apps/gameboard/gameboard.js index 977db90..77e7c1b 100644 --- a/src/apps/gameboard/static/apps/gameboard/gameboard.js +++ b/src/apps/gameboard/static/apps/gameboard/gameboard.js @@ -9,6 +9,11 @@ function initGameKitTooltips() { const gameKit = document.getElementById('id_game_kit'); if (!portal || !miniPortal || !gameKit) return; + // Start portals hidden — ensures is_displayed() works correctly in tests + // that run without CSS (StaticLiveServerTestCase). + portal.style.display = 'none'; + miniPortal.style.display = 'none'; + let equippedId = gameKit.dataset.equippedId || ''; let activeToken = null; let equipping = false; @@ -19,8 +24,9 @@ function initGameKitTooltips() { function closePortals() { portal.classList.remove('active'); + portal.style.display = 'none'; miniPortal.classList.remove('active'); - miniPortal.style.display = ''; + miniPortal.style.display = 'none'; activeToken = null; } @@ -44,33 +50,66 @@ function initGameKitTooltips() { } }); - function buildMiniContent(tokenId) { - if (equippedId && tokenId === equippedId) { - miniPortal.textContent = 'Equipped'; - } else { - const btn = document.createElement('button'); - btn.className = 'equip-trinket-btn'; - btn.dataset.tokenId = tokenId; - btn.textContent = 'Equip Trinket?'; - btn.addEventListener('click', (e) => { - e.stopPropagation(); - equipping = true; - equippedId = tokenId; - gameKit.dataset.equippedId = equippedId; - fetch(`/gameboard/equip-trinket/${tokenId}/`, { - method: 'POST', - headers: {'X-CSRFToken': getCsrfToken()}, - }).then(r => { - if (r.ok && equipping) { - equipping = false; - closePortals(); - } else { - equipping = false; - } + // buildMiniContent takes the full element so it can inspect data-deck-id vs data-token-id. + function buildMiniContent(token) { + const deckId = token.dataset.deckId; + const tokenId = token.dataset.tokenId; + + if (deckId) { + const equippedDeckId = gameKit.dataset.equippedDeckId || ''; + if (equippedDeckId && deckId === equippedDeckId) { + miniPortal.textContent = 'Equipped'; + } else { + const btn = document.createElement('button'); + btn.className = 'equip-deck-btn'; + btn.textContent = 'Equip Deck?'; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + equipping = true; + gameKit.dataset.equippedDeckId = deckId; + fetch(`/gameboard/equip-deck/${deckId}/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }).then(r => { + if (r.ok && equipping) { + equipping = false; + closePortals(); + } else { + equipping = false; + } + }); }); - }); - miniPortal.innerHTML = ''; - miniPortal.appendChild(btn); + miniPortal.innerHTML = ''; + miniPortal.appendChild(btn); + } + } else if (tokenId) { + if (equippedId && tokenId === equippedId) { + miniPortal.textContent = 'Equipped'; + } else { + const btn = document.createElement('button'); + btn.className = 'equip-trinket-btn'; + btn.dataset.tokenId = tokenId; + btn.textContent = 'Equip Trinket?'; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + equipping = true; + equippedId = tokenId; + gameKit.dataset.equippedId = equippedId; + fetch(`/gameboard/equip-trinket/${tokenId}/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }).then(r => { + if (r.ok && equipping) { + equipping = false; + closePortals(); + } else { + equipping = false; + } + }); + }); + miniPortal.innerHTML = ''; + miniPortal.appendChild(btn); + } } } @@ -80,18 +119,19 @@ function initGameKitTooltips() { const tooltip = token.querySelector('.token-tooltip'); portal.innerHTML = tooltip.innerHTML; portal.classList.add('active'); + portal.style.display = 'block'; - const isEquippable = !!token.dataset.tokenId; + const isEquippable = !!(token.dataset.tokenId || token.dataset.deckId); let miniHeight = 0; if (isEquippable) { - buildMiniContent(token.dataset.tokenId); + buildMiniContent(token); miniPortal.classList.add('active'); miniPortal.style.display = 'block'; miniHeight = miniPortal.offsetHeight + 4; } else { miniPortal.classList.remove('active'); - miniPortal.style.display = ''; + miniPortal.style.display = 'none'; } const tokenRect = token.getBoundingClientRect(); diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 90d7c6f..0015869 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -50,8 +50,11 @@ class GameboardViewTest(TestCase): def test_game_kit_has_free_token(self): [_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token") - def test_game_kit_has_card_deck_placeholder(self): - [_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck") + def test_game_kit_shows_deck_variant_cards(self): + decks = self.parsed.cssselect("#id_game_kit .deck-variant") + self.assertGreater(len(decks), 0) + # Earthman deck (seeded by migration) should have its own card + [_] = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck") def test_game_kit_has_dice_set_placeholder(self): [_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set") diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index f6b37b8..d38da8b 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -7,5 +7,6 @@ urlpatterns = [ path('', views.gameboard, name='gameboard'), path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'), path('equip-trinket//', views.equip_trinket, name='equip_trinket'), + path('equip-deck//', views.equip_deck, name='equip_deck'), ] diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 2d179c2..1f9acc8 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -6,7 +6,7 @@ from django.utils import timezone from apps.applets.utils import applet_context from apps.applets.models import Applet, UserApplet -from apps.epic.models import Room, RoomInvite +from apps.epic.models import DeckVariant, Room, RoomInvite from apps.lyric.models import Token @@ -31,6 +31,8 @@ def gameboard(request): "coin": coin, "carte": carte, "equipped_trinket_id": str(request.user.equipped_trinket_id or ""), + "equipped_deck_id": str(request.user.equipped_deck_id or ""), + "deck_variants": list(DeckVariant.objects.all()), "free_tokens": free_tokens, "free_count": len(free_tokens), "applets": applet_context(request.user, "gameboard"), @@ -59,6 +61,8 @@ def toggle_game_applets(request): "coin": request.user.tokens.filter(token_type=Token.COIN).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "equipped_trinket_id": str(request.user.equipped_trinket_id or ""), + "equipped_deck_id": str(request.user.equipped_deck_id or ""), + "deck_variants": list(DeckVariant.objects.all()), "free_tokens": list(request.user.tokens.filter( token_type=Token.FREE, expires_at__gt=timezone.now() ).order_by("expires_at")), @@ -86,3 +90,13 @@ def equip_trinket(request, token_id): "apps/gameboard/_partials/_equip_trinket_btn.html", {"token": token}, ) + + +@login_required(login_url="/") +def equip_deck(request, deck_id): + deck = get_object_or_404(DeckVariant, pk=deck_id) + if request.method == "POST": + request.user.equipped_deck = deck + request.user.save(update_fields=["equipped_deck"]) + return HttpResponse(status=204) + return HttpResponse(status=405) diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index c7077eb..2ff0853 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -221,6 +221,30 @@ class CarteTokenCreationTest(TestCase): self.assertIsNone(token.expires_at) +class EquippedDeckTest(TestCase): + def test_new_user_gets_earthman_as_default_deck(self): + from apps.epic.models import DeckVariant + earthman = DeckVariant.objects.get(slug="earthman") + user = User.objects.create(email="deck@test.io") + user.refresh_from_db() + self.assertEqual(user.equipped_deck, earthman) + + def test_fiorentine_is_not_auto_assigned_to_new_users(self): + from apps.epic.models import DeckVariant + fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + user = User.objects.create(email="deck2@test.io") + user.refresh_from_db() + self.assertNotEqual(user.equipped_deck, fiorentine) + + def test_equipped_deck_can_be_switched(self): + from apps.epic.models import DeckVariant + fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + user = User.objects.create(email="deck3@test.io") + user.equipped_deck = fiorentine + user.save(update_fields=["equipped_deck"]) + self.assertEqual(User.objects.get(pk=user.pk).equipped_deck, fiorentine) + + class PaymentMethodTest(TestCase): def setUp(self): self.user = User.objects.create(email="pay@test.io") diff --git a/src/functional_tests/test_component_cards_tarot.py b/src/functional_tests/test_component_cards_tarot.py index 7c0c9e0..7fd1338 100644 --- a/src/functional_tests/test_component_cards_tarot.py +++ b/src/functional_tests/test_component_cards_tarot.py @@ -220,14 +220,22 @@ class GameKitDeckSelectionTest(FunctionalTest): "grid_rows": rows, "context": "gameboard", }, ) + # DeckVariant rows are flushed by TransactionTestCase — recreate before + # creating the user so the post_save signal can set equipped_deck = earthman. + self.earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + self.fiorentine, _ = DeckVariant.objects.get_or_create( + slug="fiorentine-minchiate", + defaults={"name": "Fiorentine Minchiate", "card_count": 78, "is_default": False}, + ) self.gamer = User.objects.create(email="gamer@deck.io") - # TODO: once DeckVariant model is defined — - # from apps.epic.models import DeckVariant - # self.earthman = DeckVariant.objects.get(slug="earthman") - # self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") - # # Put gamer on Fiorentine so the test can show switching back to Earthman - # self.gamer.equipped_deck = self.fiorentine - # self.gamer.save(update_fields=["equipped_deck"]) + # Signal sets equipped_deck = earthman (now it exists); put gamer on + # Fiorentine so the test can exercise switching back to Earthman. + self.gamer.refresh_from_db() + self.gamer.equipped_deck = self.fiorentine + self.gamer.save(update_fields=["equipped_deck"]) # ------------------------------------------------------------------ # # Test 5 — Game Kit shows deck cards with correct equip/equipped state # diff --git a/src/templates/apps/gameboard/_partials/_applet-game-kit.html b/src/templates/apps/gameboard/_partials/_applet-game-kit.html index 50afb9e..5544821 100644 --- a/src/templates/apps/gameboard/_partials/_applet-game-kit.html +++ b/src/templates/apps/gameboard/_partials/_applet-game-kit.html @@ -3,7 +3,7 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

Game Kit

-
+
{% if pass_token %}
@@ -66,7 +66,19 @@
{% endwith %} {% endif %} + {% for deck in deck_variants %} +
+ +
+
+

{{ deck.name }}

+

{{ deck.card_count }} cards

+
+
+
+ {% empty %}
+ {% endfor %}