game kit page: four 6×3 applets (trinkets, tokens, card decks, dice sets) with applet grid; tarot fan modal with coverflow, sessionStorage position memory, and 403 guard on locked decks; unlocked_decks M2M on User with backfill migration; game kit icon wrap fix; tarot_deck.html moved to gameboard/ per template dir convention (now documented in CLAUDE.md); FTs 6–13, 2 new ITs; 360 passing [log Co-Authored-By: Claude Sonnet 4.6]

This commit is contained in:
Disco DeDisco
2026-03-24 22:57:12 -04:00
parent b03ba09b65
commit a5d71925fc
10 changed files with 580 additions and 1 deletions

View File

@@ -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);

View File

@@ -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)

View File

@@ -8,5 +8,7 @@ urlpatterns = [
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-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
path('game-kit/', views.game_kit, name='game_kit'),
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
]

View File

@@ -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,
})