From a5d71925fc868e59ec6a6d59b00c70456106ab8e Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 24 Mar 2026 22:57:12 -0400 Subject: [PATCH] =?UTF-8?q?game=20kit=20page:=20four=206=C3=973=20applets?= =?UTF-8?q?=20(trinkets,=20tokens,=20card=20decks,=20dice=20sets)=20with?= =?UTF-8?q?=20applet=20grid;=20tarot=20fan=20modal=20with=20coverflow,=20s?= =?UTF-8?q?essionStorage=20position=20memory,=20and=20403=20guard=20on=20l?= =?UTF-8?q?ocked=20decks;=20unlocked=5Fdecks=20M2M=20on=20User=20with=20ba?= =?UTF-8?q?ckfill=20migration;=20game=20kit=20icon=20wrap=20fix;=20tarot?= =?UTF-8?q?=5Fdeck.html=20moved=20to=20gameboard/=20per=20template=20dir?= =?UTF-8?q?=20convention=20(now=20documented=20in=20CLAUDE.md);=20FTs=206?= =?UTF-8?q?=E2=80=9313,=202=20new=20ITs;=20360=20passing=20[log=20Co-Autho?= =?UTF-8?q?red-By:=20Claude=20Sonnet=204.6]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../static/apps/gameboard/game-kit.js | 106 ++++++++++++ .../gameboard/tests/integrated/test_views.py | 18 ++ src/apps/gameboard/urls.py | 2 + src/apps/gameboard/views.py | 33 ++++ .../test_component_cards_tarot.py | 160 ++++++++++++++++++ src/static_src/scss/_applets.scss | 1 + src/static_src/scss/_game-kit.scss | 146 ++++++++++++++++ .../gameboard/_partials/_applet-game-kit.html | 2 +- .../apps/gameboard/_partials/_tarot_fan.html | 15 ++ src/templates/apps/gameboard/game_kit.html | 98 +++++++++++ 10 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 src/apps/gameboard/static/apps/gameboard/game-kit.js create mode 100644 src/templates/apps/gameboard/_partials/_tarot_fan.html create mode 100644 src/templates/apps/gameboard/game_kit.html diff --git a/src/apps/gameboard/static/apps/gameboard/game-kit.js b/src/apps/gameboard/static/apps/gameboard/game-kit.js new file mode 100644 index 0000000..462b589 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/game-kit.js @@ -0,0 +1,106 @@ +function initGameKitPage() { + const dialog = document.getElementById('id_tarot_fan_dialog'); + if (!dialog) return; + + const fanContent = document.getElementById('id_fan_content'); + const prevBtn = document.getElementById('id_fan_prev'); + const nextBtn = document.getElementById('id_fan_next'); + + let currentDeckId = null; + let currentIndex = 0; + let cards = []; + + function storageKey(deckId) { + return 'tarot-fan-' + deckId; + } + + function savePosition() { + if (currentDeckId !== null) { + sessionStorage.setItem(storageKey(currentDeckId), currentIndex); + } + } + + function restorePosition(deckId) { + const saved = sessionStorage.getItem(storageKey(deckId)); + return saved !== null ? parseInt(saved, 10) : 0; + } + + function cardTransform(offset) { + const abs = Math.abs(offset); + return { + transform: 'translateX(' + (offset * 200) + 'px) rotateY(' + (offset * 22) + 'deg) scale(' + Math.max(0.3, 1 - abs * 0.15) + ')', + opacity: Math.max(0.15, 1 - abs * 0.25), + zIndex: 10 - abs, + }; + } + + function updateFan() { + const total = cards.length; + if (!total) return; + cards.forEach(function(card, i) { + let offset = i - currentIndex; + if (offset > total / 2) offset -= total; + if (offset < -total / 2) offset += total; + + const abs = Math.abs(offset); + card.classList.toggle('fan-card--active', offset === 0); + + if (abs > 3) { + card.style.display = 'none'; + } else { + card.style.display = ''; + const t = cardTransform(offset); + card.style.transform = t.transform; + card.style.opacity = t.opacity; + card.style.zIndex = t.zIndex; + } + }); + } + + function openFan(deckId) { + currentDeckId = deckId; + currentIndex = restorePosition(deckId); + + fetch('/gameboard/game-kit/deck/' + deckId + '/') + .then(function(r) { return r.text(); }) + .then(function(html) { + fanContent.innerHTML = html; + cards = Array.from(fanContent.querySelectorAll('.fan-card')); + if (currentIndex >= cards.length) currentIndex = 0; + updateFan(); + dialog.showModal(); + }); + } + + function closeFan() { + savePosition(); + dialog.close(); + } + + function navigate(delta) { + if (!cards.length) return; + currentIndex = (currentIndex + delta + cards.length) % cards.length; + savePosition(); + updateFan(); + } + + // Click on the dialog background (outside .tarot-fan-wrap) closes the modal + dialog.addEventListener('click', function(e) { + if (!e.target.closest('.tarot-fan-wrap')) closeFan(); + }); + + // Arrow key navigation + dialog.addEventListener('keydown', function(e) { + if (e.key === 'ArrowRight') navigate(1); + if (e.key === 'ArrowLeft') navigate(-1); + }); + + prevBtn.addEventListener('click', function() { navigate(-1); }); + nextBtn.addEventListener('click', function() { navigate(1); }); + + document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function(card) { + card.addEventListener('click', function() { openFan(card.dataset.deckId); }); + }); +} + +document.addEventListener('DOMContentLoaded', initGameKitPage); diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 0015869..758f551 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -118,3 +118,21 @@ class EquipTrinketViewTest(TestCase): ) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html") + + +class TarotFanViewTest(TestCase): + def setUp(self): + from apps.epic.models import DeckVariant + self.earthman = DeckVariant.objects.get(slug="earthman") + self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + self.user = User.objects.create(email="fan@test.io") + self.client.force_login(self.user) + + def test_returns_fan_partial_for_unlocked_deck(self): + response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk})) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html") + + def test_returns_403_for_locked_deck(self): + response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk})) + self.assertEqual(response.status_code, 403) diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index d38da8b..ea42837 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -8,5 +8,7 @@ urlpatterns = [ 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'), + path('game-kit/', views.game_kit, name='game_kit'), + path('game-kit/deck//', views.tarot_fan, name='tarot_fan'), ] diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 8cd59d0..b1541aa 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -100,3 +100,36 @@ def equip_deck(request, deck_id): request.user.save(update_fields=["equipped_deck"]) return HttpResponse(status=204) return HttpResponse(status=405) + + +@login_required(login_url="/") +def game_kit(request): + coin = request.user.tokens.filter(token_type=Token.COIN).first() + pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None + carte = request.user.tokens.filter(token_type=Token.CARTE).first() + free_tokens = list(request.user.tokens.filter( + token_type=Token.FREE, expires_at__gt=timezone.now() + ).order_by("expires_at")) + tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE)) + return render(request, "apps/gameboard/game_kit.html", { + "coin": coin, + "pass_token": pass_token, + "carte": carte, + "free_tokens": free_tokens, + "tithe_tokens": tithe_tokens, + "unlocked_decks": list(request.user.unlocked_decks.all()), + "page_class": "page-gameboard", + }) + + +@login_required(login_url="/") +def tarot_fan(request, deck_id): + from apps.epic.models import TarotCard + deck = get_object_or_404(DeckVariant, pk=deck_id) + if not request.user.unlocked_decks.filter(pk=deck_id).exists(): + return HttpResponse(status=403) + cards = list(TarotCard.objects.filter(deck_variant=deck).order_by("arcana", "number")) + return render(request, "apps/gameboard/_partials/_tarot_fan.html", { + "deck": deck, + "cards": cards, + }) diff --git a/src/functional_tests/test_component_cards_tarot.py b/src/functional_tests/test_component_cards_tarot.py index 45b615f..20436d6 100644 --- a/src/functional_tests/test_component_cards_tarot.py +++ b/src/functional_tests/test_component_cards_tarot.py @@ -342,3 +342,163 @@ class GameKitDeckSelectionTest(FunctionalTest): self.browser.find_element(By.ID, "id_kit_earthman_deck") fiorentine_cards = self.browser.find_elements(By.ID, "id_kit_fiorentine_deck") self.assertEqual(len(fiorentine_cards), 0) + + +class GameKitPageTest(FunctionalTest): + """ + User navigates from gameboard to the dedicated game-kit page. + The page shows four rows: trinkets, tokens, card decks, dice placeholder. + Clicking a deck card opens a tarot fan modal with coverflow navigation. + """ + + def setUp(self): + super().setUp() + from apps.epic.models import TarotCard + for slug, name, cols, rows in [ + ("new-game", "New Game", 6, 3), + ("my-games", "My Games", 6, 3), + ("game-kit", "Game Kit", 6, 3), + ]: + Applet.objects.get_or_create( + slug=slug, + defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "gameboard"}, + ) + self.earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + # Seed 10 cards — enough to demonstrate full 7-card coverflow + for i in range(10): + TarotCard.objects.get_or_create( + deck_variant=self.earthman, + slug=f"gkp-card-{i}", + defaults={"name": f"Card {i}", "arcana": "MAJOR", "number": i}, + ) + # Create user after decks so signal sets equipped_deck + unlocked_decks + self.gamer = User.objects.create(email="gamer@kit.io") + self.gamer.refresh_from_db() + self.create_pre_authenticated_session("gamer@kit.io") + + # ------------------------------------------------------------------ # + # Test 7 — gameboard Game Kit heading links to dedicated page # + # ------------------------------------------------------------------ # + + def test_gameboard_game_kit_heading_links_to_game_kit_page(self): + self.browser.get(self.live_server_url + "/gameboard/") + link = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_game_kit h2 a") + ) + link.click() + self.wait_for(lambda: self.assertIn("/gameboard/game-kit/", self.browser.current_url)) + + # ------------------------------------------------------------------ # + # Test 8 — game-kit page shows four rows # + # ------------------------------------------------------------------ # + + def test_game_kit_page_shows_four_rows(self): + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_gk_trinkets")) + self.browser.find_element(By.ID, "id_gk_tokens") + self.browser.find_element(By.ID, "id_gk_decks") + self.browser.find_element(By.ID, "id_gk_dice") + + # ------------------------------------------------------------------ # + # Test 9 — clicking a deck card opens the tarot fan modal # + # ------------------------------------------------------------------ # + + def test_clicking_deck_opens_tarot_fan_modal(self): + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") + ).click() + dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog") + self.wait_for(lambda: self.assertTrue(dialog.is_displayed())) + + # ------------------------------------------------------------------ # + # Test 10 — fan shows active center card plus receding cards # + # ------------------------------------------------------------------ # + + def test_fan_shows_active_card_and_receding_cards(self): + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") + ).click() + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active")) + visible = self.browser.find_elements( + By.CSS_SELECTOR, "#id_fan_content .fan-card:not([style*='display: none'])" + ) + self.assertGreater(len(visible), 1) + + # ------------------------------------------------------------------ # + # Test 11 — next button advances the active card # + # ------------------------------------------------------------------ # + + def test_fan_next_button_advances_card(self): + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") + ).click() + first_index = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active") + ).get_attribute("data-index") + self.browser.find_element(By.ID, "id_fan_next").click() + self.wait_for( + lambda: self.assertNotEqual( + self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"), + first_index, + ) + ) + + # ------------------------------------------------------------------ # + # Test 12 — clicking outside the modal closes it # + # ------------------------------------------------------------------ # + + def test_clicking_outside_fan_closes_modal(self): + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") + ).click() + dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog") + self.wait_for(lambda: self.assertTrue(dialog.is_displayed())) + # Dispatch a click directly on the dialog element (simulates clicking the dark backdrop) + self.browser.execute_script( + "document.getElementById('id_tarot_fan_dialog')" + ".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))" + ) + self.wait_for(lambda: self.assertFalse(dialog.is_displayed())) + + # ------------------------------------------------------------------ # + # Test 13 — reopening the modal remembers scroll position # + # ------------------------------------------------------------------ # + + def test_fan_remembers_position_on_reopen(self): + self.browser.get(self.live_server_url + "/gameboard/game-kit/") + deck_card = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card") + ) + deck_card.click() + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active")) + # Advance 3 cards + for _ in range(3): + self.browser.find_element(By.ID, "id_fan_next").click() + saved_index = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index") + ) + # Close + self.browser.execute_script( + "document.getElementById('id_tarot_fan_dialog')" + ".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))" + ) + self.wait_for( + lambda: self.assertFalse( + self.browser.find_element(By.ID, "id_tarot_fan_dialog").is_displayed() + ) + ) + # Reopen and verify position restored + deck_card.click() + self.wait_for( + lambda: self.assertEqual( + self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"), + saved_index, + ) + ) diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index af33fc2..2090f68 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -219,3 +219,4 @@ #id_game_applets_container { @extend %applets-grid; } #id_wallet_applets_container { @extend %applets-grid; } #id_billboard_applets_container { @extend %applets-grid; } +#id_game_kit_applets_container { @extend %applets-grid; } diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index eda947c..c353191 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -115,3 +115,149 @@ font-size: 0.7rem; color: rgba(var(--secUser), 0.4); } + +// ── Game Kit page ──────────────────────────────────────────────────────────── + +#id_game_kit_applets_container section { + display: flex; + flex-direction: column; + + h2 { flex-shrink: 0; } + .gk-items { flex: 1; overflow-y: auto; } +} + +.gk-items { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.gk-deck-card, +.gk-trinket-card, +.gk-token-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + border: 0.1rem solid rgba(var(--secUser), 0.3); + cursor: pointer; + font-size: 1.5rem; + min-width: 6rem; + text-align: center; + transition: border-color 0.15s; + + span { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; } + small { font-size: 0.6rem; opacity: 0.5; } + + &:hover { border-color: rgba(var(--secUser), 0.8); } +} + +.gk-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + border: 0.1rem dashed rgba(var(--secUser), 0.2); + font-size: 1.5rem; + min-width: 6rem; + text-align: center; + opacity: 0.4; + + span { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; } +} + +.gk-empty { + font-size: 0.8rem; + opacity: 0.45; +} + +// ── Tarot fan modal ────────────────────────────────────────────────────────── + +#id_tarot_fan_dialog { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: none; + background: rgba(0, 0, 0, 0.88); + overflow: hidden; + + &::backdrop { display: none; } // Dialog IS the backdrop +} + +.tarot-fan-wrap { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + perspective: 900px; +} + +.tarot-fan { + position: relative; + width: 220px; + height: 340px; +} + +.fan-card { + position: absolute; + inset: 0; + width: 220px; + height: 340px; + border-radius: 0.75rem; + background: rgba(var(--priUser), 1); + border: 0.1rem solid rgba(var(--secUser), 0.4); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.25s ease, opacity 0.25s ease; + transform-style: preserve-3d; + + &--active { + border-color: rgba(var(--secUser), 1); + box-shadow: 0 0 2rem rgba(var(--secUser), 0.3); + } +} + +.fan-card-face { + padding: 1.25rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 0.5rem; + + .fan-card-number { font-size: 0.65rem; opacity: 0.5; } + .fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; } + .fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.6; } + .fan-card-correspondence { font-size: 0.6rem; opacity: 0.45; font-style: italic; } +} + +.fan-nav { + position: absolute; + z-index: 20; + font-size: 3rem; + line-height: 1; + background: none; + border: none; + color: rgba(var(--secUser), 0.6); + cursor: pointer; + padding: 1rem; + transition: color 0.15s; + pointer-events: auto; + + &:hover { color: rgba(var(--secUser), 1); } + &--prev { left: 1rem; } + &--next { right: 1rem; } +} diff --git a/src/templates/apps/gameboard/_partials/_applet-game-kit.html b/src/templates/apps/gameboard/_partials/_applet-game-kit.html index 5544821..0830a25 100644 --- a/src/templates/apps/gameboard/_partials/_applet-game-kit.html +++ b/src/templates/apps/gameboard/_partials/_applet-game-kit.html @@ -2,7 +2,7 @@ id="id_applet_game_kit" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" > -

Game Kit

+

Game Kit

{% if pass_token %}
diff --git a/src/templates/apps/gameboard/_partials/_tarot_fan.html b/src/templates/apps/gameboard/_partials/_tarot_fan.html new file mode 100644 index 0000000..642eda5 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_tarot_fan.html @@ -0,0 +1,15 @@ +{% for card in cards %} +
+
+

{{ card.number }}

+

{{ card.name }}

+

{{ card.get_arcana_display }}

+ {% if card.correspondence %} +

{{ card.correspondence }}

+ {% endif %} + {% if card.suit %} +

{{ card.suit }}

+ {% endif %} +
+
+{% endfor %} diff --git a/src/templates/apps/gameboard/game_kit.html b/src/templates/apps/gameboard/game_kit.html new file mode 100644 index 0000000..cd6a5f2 --- /dev/null +++ b/src/templates/apps/gameboard/game_kit.html @@ -0,0 +1,98 @@ +{% extends "core/base.html" %} +{% load static %} + +{% block title_text %}Game Kit{% endblock title_text %} +{% block header_text %}GameKit{% endblock header_text %} + +{% block content %} +
+
+ +
+

Trinkets

+
+ {% if pass_token %} +
+ + {{ pass_token.tooltip_name }} +
+ {% endif %} + {% if carte %} +
+ + {{ carte.tooltip_name }} +
+ {% endif %} + {% if coin %} +
+ + {{ coin.tooltip_name }} +
+ {% endif %} + {% if not pass_token and not carte and not coin %} +

No trinkets yet.

+ {% endif %} +
+
+ +
+

Tokens

+
+ {% for token in free_tokens %} +
+ + {{ token.tooltip_name }} +
+ {% endfor %} + {% for token in tithe_tokens %} +
+ + {{ token.tooltip_name }} +
+ {% endfor %} + {% if not free_tokens and not tithe_tokens %} +

No tokens yet.

+ {% endif %} +
+
+ +
+

Card Decks

+
+ {% for deck in unlocked_decks %} +
+ + {{ deck.name }} + {{ deck.card_count }} cards +
+ {% empty %} +

No decks unlocked.

+ {% endfor %} +
+
+ +
+

Dice Sets

+
+
+ + Coming soon +
+
+
+ +
+
+ + +
+ +
+ +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %}