deck contribution sprint 2 + Carte Blanche safeguards — TDD

Sprint 2 UI (game kit applet):
- _applet-game-kit.html: in-use deck → two disabled × buttons, .tt-deck-game-name;
  in-use Carte Blanche → two disabled × buttons, data-current-room-name,
  .tt-token-room-name; tooltip content mirrors kit bag panel (Default, card count,
  description, Stock version)
- gameboard.js buildMiniContent: 'In-Use' for tokens w. data-current-room-name set
- _kit_bag_panel.html: Deck section always renders (placeholder when unequipped)

View safeguards:
- select_role: look up existing deck from prior seat in same room before
  equipped_deck (Carte Blanche multi-seat); only unequip when using equipped_deck
- drop_token Carte: reject 409 if token.current_room is a different room;
  unequip from equipped_trinket on drop

ITs: SelectRoleMultiSeatTest (2), DropTokenViewTest +3 (carte drop, unequip, lock)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-27 23:24:43 -04:00
parent 94a864b05b
commit fa68c74b51
8 changed files with 196 additions and 22 deletions

View File

@@ -64,10 +64,20 @@ function initGameKitTooltips() {
const tokenId = token.dataset.tokenId;
const equippedId = gameKit.dataset.equippedId || '';
const equippedDeckId = gameKit.dataset.equippedDeckId || '';
const inUseDeckIds = new Set((gameKit.dataset.inUseDeckIds || '').split(',').filter(Boolean));
if (deckId) {
miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped';
if (inUseDeckIds.has(deckId)) {
miniPortal.textContent = 'In-Use';
} else {
miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped';
}
} else if (tokenId) {
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped';
const currentRoomName = token.dataset.currentRoomName || '';
if (currentRoomName) {
miniPortal.textContent = 'In-Use';
} else {
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped';
}
}
}

View File

@@ -4,7 +4,7 @@ from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet, UserApplet
from apps.epic.models import DeckVariant
from apps.epic.models import DeckVariant, Room, TableSeat
from apps.lyric.models import Token, User
@@ -61,6 +61,45 @@ class GameboardViewTest(TestCase):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
class GameboardDeckInUseTest(TestCase):
"""Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
self.earthman = DeckVariant.objects.get(slug="earthman")
self.room = Room.objects.create(name="Wildfire", owner=self.user)
self.seat = TableSeat.objects.create(
room=self.room, gamer=self.user, slot_number=1,
deck_variant=self.earthman,
)
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
def test_in_use_deck_don_is_disabled(self):
[don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip")
self.assertIn("btn-disabled", don.get("class", ""))
def test_in_use_deck_doff_is_absent(self):
active_doff = self.parsed.cssselect(
"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
)
self.assertEqual(len(active_doff), 0)
def test_in_use_deck_tooltip_shows_game_name(self):
[label] = self.parsed.cssselect("#id_kit_earthman_deck .tt-deck-game-name")
self.assertIn("Wildfire", label.text_content())
def test_non_in_use_deck_has_normal_don(self):
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
self.user.unlocked_decks.add(fiorentine)
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
[don] = parsed.cssselect("#id_kit_fiorentine_deck .btn-equip")
self.assertNotIn("btn-disabled", don.get("class", ""))
class ToggleGameAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")

View File

@@ -4,7 +4,20 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.epic.models import DeckVariant, Room
def _annotate_deck_in_use(decks, user):
"""Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
active = {
ts.deck_variant_id: ts.room.name
for ts in TableSeat.objects.filter(
gamer=user, deck_variant__isnull=False,
).select_related("room")
}
for deck in decks:
deck.in_use_room_name = active.get(deck.pk)
return decks
from apps.epic.models import DeckVariant, Room, TableSeat
from apps.epic.utils import rooms_for_user
from apps.lyric.models import Token
@@ -31,7 +44,7 @@ def gameboard(request):
"carte": carte,
"equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": list(request.user.unlocked_decks.all()),
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
"free_tokens": free_tokens,
"free_count": len(free_tokens),
"applets": applet_context(request.user, "gameboard"),
@@ -55,7 +68,7 @@ def toggle_game_applets(request):
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": list(request.user.unlocked_decks.all()),
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
"free_tokens": free_tokens,
"free_count": len(free_tokens),
"my_games": rooms_for_user(request.user),