diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 964e7e8..ea8de8d 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -142,6 +142,39 @@ class DropTokenViewTest(TestCase): response, reverse("epic:gatekeeper", args=[self.room.id]) ) + def test_carte_drop_sets_current_room(self): + carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE) + self.client.post( + reverse("epic:drop_token", kwargs={"room_id": self.room.id}), + data={"token_id": carte.pk}, + ) + carte.refresh_from_db() + self.assertEqual(carte.current_room, self.room) + + def test_carte_drop_unequips_trinket(self): + carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE) + self.gamer.equipped_trinket = carte + self.gamer.save(update_fields=["equipped_trinket"]) + self.client.post( + reverse("epic:drop_token", kwargs={"room_id": self.room.id}), + data={"token_id": carte.pk}, + ) + self.gamer.refresh_from_db() + self.assertIsNone(self.gamer.equipped_trinket) + + def test_carte_drop_rejected_when_already_in_different_room(self): + other_room = Room.objects.create(name="Other Room", owner=self.gamer) + carte = Token.objects.create( + user=self.gamer, token_type=Token.CARTE, current_room=other_room, + ) + response = self.client.post( + reverse("epic:drop_token", kwargs={"room_id": self.room.id}), + data={"token_id": carte.pk}, + ) + self.assertEqual(response.status_code, 409) + carte.refresh_from_db() + self.assertEqual(carte.current_room, other_room) # unchanged + class ConfirmTokenViewTest(TestCase): def setUp(self): @@ -701,6 +734,15 @@ class SelectRoleViewTest(TestCase): seat = TableSeat.objects.get(room=self.room, slot_number=1) self.assertIsNone(seat.deck_variant) + def test_select_role_unequips_deck_from_user(self): + earthman = DeckVariant.objects.get(slug="earthman") + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "PC"}, + ) + self.founder.refresh_from_db() + self.assertIsNone(self.founder.equipped_deck) + def test_select_role_requires_login(self): self.client.logout() response = self.client.post( @@ -764,6 +806,55 @@ class SelectRoleViewTest(TestCase): ) +class SelectRoleMultiSeatTest(TestCase): + """Carte Blanche multi-seat: second role reuses the deck from the first seat.""" + + def setUp(self): + self.founder = User.objects.create(email="founder@test.io") + self.client.force_login(self.founder) + self.room = Room.objects.create(name="Test Room", owner=self.founder) + self.room.gate_status = Room.OPEN + self.room.table_status = Room.ROLE_SELECT + self.room.save() + self.earthman = DeckVariant.objects.get(slug="earthman") + + def test_second_role_inherits_deck_from_first_seat_in_room(self): + # Founder's first seat: PC already taken with deck assigned + TableSeat.objects.create( + room=self.room, gamer=self.founder, slot_number=1, + role="PC", deck_variant=self.earthman, + ) + # Deck unequipped after first role + self.founder.equipped_deck = None + self.founder.save(update_fields=["equipped_deck"]) + # Founder's second seat (Carte Blanche): no role yet + second_seat = TableSeat.objects.create( + room=self.room, gamer=self.founder, slot_number=2, + ) + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "BC"}, + ) + second_seat.refresh_from_db() + self.assertEqual(second_seat.deck_variant, self.earthman) + + def test_second_role_does_not_unequip_again(self): + """No-op unequip when deck was already cleared by the first role.""" + TableSeat.objects.create( + room=self.room, gamer=self.founder, slot_number=1, + role="PC", deck_variant=self.earthman, + ) + self.founder.equipped_deck = None + self.founder.save(update_fields=["equipped_deck"]) + TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2) + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "BC"}, + ) + self.founder.refresh_from_db() + self.assertIsNone(self.founder.equipped_deck) # still None, not broken + + class RoomViewAllRolesFilledTest(TestCase): """Room view in ROLE_SELECT with all seats assigned shows PICK SIGS button.""" def setUp(self): diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 0702713..b654a94 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -405,8 +405,13 @@ def drop_token(request, room_id): if token.token_type == Token.CARTE: # CARTE enters the machine without reserving a slot — all slots # become individually claimable via .drop-token-btn + if token.current_room_id and token.current_room_id != room.id: + return HttpResponse(status=409) token.current_room = room token.save() + if request.user.equipped_trinket_id == token.pk: + request.user.equipped_trinket = None + request.user.save(update_fields=["equipped_trinket"]) request.session["kit_token_id"] = str(token.id) _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) @@ -566,6 +571,7 @@ def select_role(request, room_id): valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES] if not role or role not in valid_roles: return redirect("epic:room", room_id=room_id) + existing = None with transaction.atomic(): active_seat = room.table_seats.select_for_update().filter( role__isnull=True @@ -575,8 +581,16 @@ def select_role(request, room_id): if room.table_seats.filter(role=role).exists(): return HttpResponse(status=409) active_seat.role = role - active_seat.deck_variant = request.user.equipped_deck + existing = room.table_seats.filter( + gamer=request.user, deck_variant__isnull=False, + ).exclude(pk=active_seat.pk).first() + active_seat.deck_variant = ( + existing.deck_variant if existing else request.user.equipped_deck + ) active_seat.save() + if not existing and request.user.equipped_deck: + request.user.equipped_deck = None + request.user.save(update_fields=["equipped_deck"]) record(room, GameEvent.ROLE_SELECTED, actor=request.user, role=role, slot_number=active_seat.slot_number, role_display=dict(TableSeat.ROLE_CHOICES).get(role, role)) diff --git a/src/apps/gameboard/static/apps/gameboard/gameboard.js b/src/apps/gameboard/static/apps/gameboard/gameboard.js index 50100d1..135513c 100644 --- a/src/apps/gameboard/static/apps/gameboard/gameboard.js +++ b/src/apps/gameboard/static/apps/gameboard/gameboard.js @@ -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'; + } } } diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 961cb99..9354a9a 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -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") diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 42c41ef..b2181c7 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -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), diff --git a/src/functional_tests/test_deck_contribution.py b/src/functional_tests/test_deck_contribution.py index ae2dc81..7b704d4 100644 --- a/src/functional_tests/test_deck_contribution.py +++ b/src/functional_tests/test_deck_contribution.py @@ -96,7 +96,7 @@ class DeckContributionTest(FunctionalTest): )) # Navigate to Game Kit → Card Decks to verify UI state - self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.browser.get(self.live_server_url + "/gameboard/") decks_btn = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") ) @@ -152,7 +152,7 @@ class DeckInUseGameKitTest(FunctionalTest): session_key = self.create_pre_authenticated_session(GAMER_EMAIL) self.browser.get(self.live_server_url) self.browser.add_cookie({"name": "sessionid", "value": session_key}) - self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.browser.get(self.live_server_url + "/gameboard/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") ).click() @@ -180,7 +180,7 @@ class DeckInUseGameKitTest(FunctionalTest): session_key = self.create_pre_authenticated_session(GAMER_EMAIL) self.browser.get(self.live_server_url) self.browser.add_cookie({"name": "sessionid", "value": session_key}) - self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.browser.get(self.live_server_url + "/gameboard/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") ).click() @@ -201,7 +201,7 @@ class DeckInUseGameKitTest(FunctionalTest): session_key = self.create_pre_authenticated_session(GAMER_EMAIL) self.browser.get(self.live_server_url) self.browser.add_cookie({"name": "sessionid", "value": session_key}) - self.browser.get(self.live_server_url + "/gameboard/game-kit/") + self.browser.get(self.live_server_url + "/gameboard/") self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck") ).click() diff --git a/src/templates/apps/gameboard/_partials/_applet-game-kit.html b/src/templates/apps/gameboard/_partials/_applet-game-kit.html index c69ed77..8257519 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 }};" >
{{ carte.tooltip_description }}
@@ -33,6 +33,7 @@{{ carte.tooltip_shoptalk }}
{% endif %}{{ carte.tooltip_expiry }}
+ {% if carte.current_room %}In game: {{ carte.current_room.name }}
{% endif %}{{ deck.card_count }} cards
+{{ deck.card_count }}-card Tarot deck
+ {% if deck.description %}{{ deck.description }}
{% endif %} +Stock version (0 substitutions)
+ {% if deck.in_use_room_name %}In game: {{ deck.in_use_room_name }}
{% endif %}{{ equipped_deck.card_count }}-card Tarot deck
-placeholder comment
-active
-Stock version
+ {% if equipped_deck.description %}{{ equipped_deck.description }}
{% endif %} +Stock version (0 substitutions)