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

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

View File

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