step 17 complete: game kit deck variant cards with hover-equip mini-tooltip; DeckVariant.short_key property for template ids; equip-deck view and url in gameboard; gameboard.js unified for decks and trinkets, portals now inline-display-controlled for FT compatibility; billboard scroll fix: pos captured at event time, rAF guard prevents spurious debounce reset on first visit; 3 new ITs for Earthman deck defaults, Fiorentine not auto-assigned; gameboard IT updated for deck variant cards [git log Co-Authored-By: Claude Sonnet 4.6]
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Disco DeDisco
2026-03-24 21:52:57 -04:00
parent 2896efa8e0
commit 15ac3216ff
8 changed files with 148 additions and 41 deletions

View File

@@ -185,6 +185,11 @@ class DeckVariant(models.Model):
description = models.TextField(blank=True) description = models.TextField(blank=True)
is_default = models.BooleanField(default=False) 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): def __str__(self):
return f"{self.name} ({self.card_count} cards)" return f"{self.name} ({self.card_count} cards)"

View File

@@ -9,6 +9,11 @@ function initGameKitTooltips() {
const gameKit = document.getElementById('id_game_kit'); const gameKit = document.getElementById('id_game_kit');
if (!portal || !miniPortal || !gameKit) return; 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 equippedId = gameKit.dataset.equippedId || '';
let activeToken = null; let activeToken = null;
let equipping = false; let equipping = false;
@@ -19,8 +24,9 @@ function initGameKitTooltips() {
function closePortals() { function closePortals() {
portal.classList.remove('active'); portal.classList.remove('active');
portal.style.display = 'none';
miniPortal.classList.remove('active'); miniPortal.classList.remove('active');
miniPortal.style.display = ''; miniPortal.style.display = 'none';
activeToken = null; activeToken = null;
} }
@@ -44,33 +50,66 @@ function initGameKitTooltips() {
} }
}); });
function buildMiniContent(tokenId) { // buildMiniContent takes the full element so it can inspect data-deck-id vs data-token-id.
if (equippedId && tokenId === equippedId) { function buildMiniContent(token) {
miniPortal.textContent = 'Equipped'; const deckId = token.dataset.deckId;
} else { const tokenId = token.dataset.tokenId;
const btn = document.createElement('button');
btn.className = 'equip-trinket-btn'; if (deckId) {
btn.dataset.tokenId = tokenId; const equippedDeckId = gameKit.dataset.equippedDeckId || '';
btn.textContent = 'Equip Trinket?'; if (equippedDeckId && deckId === equippedDeckId) {
btn.addEventListener('click', (e) => { miniPortal.textContent = 'Equipped';
e.stopPropagation(); } else {
equipping = true; const btn = document.createElement('button');
equippedId = tokenId; btn.className = 'equip-deck-btn';
gameKit.dataset.equippedId = equippedId; btn.textContent = 'Equip Deck?';
fetch(`/gameboard/equip-trinket/${tokenId}/`, { btn.addEventListener('click', (e) => {
method: 'POST', e.stopPropagation();
headers: {'X-CSRFToken': getCsrfToken()}, equipping = true;
}).then(r => { gameKit.dataset.equippedDeckId = deckId;
if (r.ok && equipping) { fetch(`/gameboard/equip-deck/${deckId}/`, {
equipping = false; method: 'POST',
closePortals(); headers: {'X-CSRFToken': getCsrfToken()},
} else { }).then(r => {
equipping = false; if (r.ok && equipping) {
} equipping = false;
closePortals();
} else {
equipping = false;
}
});
}); });
}); miniPortal.innerHTML = '';
miniPortal.innerHTML = ''; miniPortal.appendChild(btn);
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'); const tooltip = token.querySelector('.token-tooltip');
portal.innerHTML = tooltip.innerHTML; portal.innerHTML = tooltip.innerHTML;
portal.classList.add('active'); portal.classList.add('active');
portal.style.display = 'block';
const isEquippable = !!token.dataset.tokenId; const isEquippable = !!(token.dataset.tokenId || token.dataset.deckId);
let miniHeight = 0; let miniHeight = 0;
if (isEquippable) { if (isEquippable) {
buildMiniContent(token.dataset.tokenId); buildMiniContent(token);
miniPortal.classList.add('active'); miniPortal.classList.add('active');
miniPortal.style.display = 'block'; miniPortal.style.display = 'block';
miniHeight = miniPortal.offsetHeight + 4; miniHeight = miniPortal.offsetHeight + 4;
} else { } else {
miniPortal.classList.remove('active'); miniPortal.classList.remove('active');
miniPortal.style.display = ''; miniPortal.style.display = 'none';
} }
const tokenRect = token.getBoundingClientRect(); const tokenRect = token.getBoundingClientRect();

View File

@@ -50,8 +50,11 @@ class GameboardViewTest(TestCase):
def test_game_kit_has_free_token(self): def test_game_kit_has_free_token(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token") [_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
def test_game_kit_has_card_deck_placeholder(self): def test_game_kit_shows_deck_variant_cards(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck") 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): def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set") [_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")

View File

@@ -7,5 +7,6 @@ urlpatterns = [
path('', views.gameboard, name='gameboard'), path('', views.gameboard, name='gameboard'),
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'), path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'), path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
] ]

View File

@@ -6,7 +6,7 @@ from django.utils import timezone
from apps.applets.utils import applet_context from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet 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 from apps.lyric.models import Token
@@ -31,6 +31,8 @@ def gameboard(request):
"coin": coin, "coin": coin,
"carte": carte, "carte": carte,
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "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_tokens": free_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
"applets": applet_context(request.user, "gameboard"), "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(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "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( "free_tokens": list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now() token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")), ).order_by("expires_at")),
@@ -86,3 +90,13 @@ def equip_trinket(request, token_id):
"apps/gameboard/_partials/_equip_trinket_btn.html", "apps/gameboard/_partials/_equip_trinket_btn.html",
{"token": token}, {"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)

View File

@@ -221,6 +221,30 @@ class CarteTokenCreationTest(TestCase):
self.assertIsNone(token.expires_at) 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): class PaymentMethodTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="pay@test.io") self.user = User.objects.create(email="pay@test.io")

View File

@@ -220,14 +220,22 @@ class GameKitDeckSelectionTest(FunctionalTest):
"grid_rows": rows, "context": "gameboard", "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") self.gamer = User.objects.create(email="gamer@deck.io")
# TODO: once DeckVariant model is defined — # Signal sets equipped_deck = earthman (now it exists); put gamer on
# from apps.epic.models import DeckVariant # Fiorentine so the test can exercise switching back to Earthman.
# self.earthman = DeckVariant.objects.get(slug="earthman") self.gamer.refresh_from_db()
# self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") self.gamer.equipped_deck = self.fiorentine
# # Put gamer on Fiorentine so the test can show switching back to Earthman self.gamer.save(update_fields=["equipped_deck"])
# 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 # # Test 5 — Game Kit shows deck cards with correct equip/equipped state #

View File

@@ -3,7 +3,7 @@
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
<h2>Game Kit</h2> <h2>Game Kit</h2>
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}"> <div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}" data-equipped-deck-id="{{ equipped_deck_id }}">
{% if pass_token %} {% if pass_token %}
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}"> <div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
@@ -66,7 +66,19 @@
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for deck in deck_variants %}
<div id="id_kit_{{ deck.short_key }}_deck" class="token deck-variant" data-deck-id="{{ deck.pk }}">
<i class="fa-regular fa-id-badge"></i>
<div class="token-tooltip">
<div class="token-tooltip-body">
<h4>{{ deck.name }}</h4>
<p>{{ deck.card_count }} cards</p>
</div>
</div>
</div>
{% empty %}
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div> <div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
{% endfor %}
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div> <div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
</div> </div>
</section> </section>