add Carte Blanche trinket: equip system, gatekeeper multi-slot, mini tooltip portal; new token type Token.CARTE ('carte') with fa-money-check icon; migrations 0010-0012:
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

CARTE type, User.equipped_trinket FK, Token.slots_claimed field; post_save signal sets
equipped_trinket=COIN for new users, PASS for staff; kit bag now shows only the equipped
trinket in Trinkets section; Game Kit applet mini tooltip portal shows Equipped or Equip
Trinket per token; AJAX POST equip-trinket id updates equippedId in-place; equip btn
now works for COIN, PASS, and CARTE (data-token-id added to all three); Gatekeeper CARTE
flow: drop_token sets current_room (no slot reserved); each empty slot up to
slots_claimed+1 gets a drop-token-btn; slots_claimed high-water mark advances on fill,
never decrements; highest CARTE-filled slot gets NVM (release_slot); token_return_btn
resets current_room + slots_claimed + un-fills all CARTE slots; gate_status always returns
full template so launch-game-btn persists via HTMX when gate_status == OPEN; room.html
includes gatekeeper when GATHERING or OPEN; new FT test_trinket_carte_blanche.py (2
tests, both passing); 299 tests green
This commit is contained in:
Disco DeDisco
2026-03-16 00:07:52 -04:00
parent b49218b45b
commit 4239245902
26 changed files with 842 additions and 105 deletions

View File

@@ -119,6 +119,8 @@ def debit_token(user, slot, token):
period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period
token.save()
elif token.token_type == Token.CARTE:
pass # current_room already set in drop_token; token not consumed
elif token.token_type != Token.PASS:
slot.debited_token_expires_at = token.expires_at
token.delete()

View File

@@ -64,11 +64,12 @@ class GateStatusViewTest(TestCase):
self.client.force_login(self.owner)
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_gate_status_returns_empty_when_open(self):
def test_gate_status_returns_launch_btn_when_open(self):
self.room.gate_status = Room.OPEN
self.room.save()
response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
self.assertEqual(response.content, b"")
self.assertEqual(response.status_code, 200)
self.assertContains(response, "launch-game-btn")
def test_gate_status_returns_partial_when_gathering(self):
response = self.client.get(

View File

@@ -10,6 +10,7 @@ urlpatterns = [
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),

View File

@@ -26,14 +26,30 @@ def _gate_context(room, user):
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
user_reserved_slot = None
user_filled_slot = None
carte_token = None
carte_slots_claimed = 0
carte_nvm_slot_number = None
if user.is_authenticated:
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
carte_token = user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte_token:
carte_slots_claimed = carte_token.slots_claimed
# NVM shown on the highest-numbered slot this user filled via CARTE
nvm_slot = slots.filter(
debited_token_type=Token.CARTE, gamer=user, status=GateSlot.FILLED
).order_by("-slot_number").first()
if nvm_slot:
carte_nvm_slot_number = nvm_slot.slot_number
carte_active = carte_token is not None
eligible = (
user.is_authenticated
and pending_slot is None
and user_reserved_slot is None
and user_filled_slot is None
and not carte_active
)
token_depleted = eligible and select_token(user) is None
can_drop = eligible and not token_depleted
@@ -41,7 +57,7 @@ def _gate_context(room, user):
user_reserved_slot is not None
and slots.filter(status=GateSlot.EMPTY).count() == 0
)
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None or carte_active
return {
"slots": slots,
"pending_slot": pending_slot,
@@ -51,6 +67,9 @@ def _gate_context(room, user):
"token_depleted": token_depleted,
"is_last_slot": is_last_slot,
"user_can_reject": user_can_reject,
"carte_active": carte_active,
"carte_slots_claimed": carte_slots_claimed,
"carte_nvm_slot_number": carte_nvm_slot_number,
}
@@ -75,10 +94,6 @@ def gatekeeper(request, room_id):
def drop_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
token_id = request.POST.get("token_id")
if token_id:
token = request.user.tokens.filter(id=token_id).first()
@@ -86,6 +101,17 @@ def drop_token(request, room_id):
token = select_token(request.user)
if token is None:
return HttpResponse(status=402)
if token.token_type == Token.CARTE:
# CARTE enters the machine without reserving a slot — all slots
# become individually claimable via .drop-token-btn
token.current_room = room
token.save()
request.session["kit_token_id"] = str(token.id)
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
status=GateSlot.EMPTY
).order_by("slot_number").first()
@@ -102,18 +128,35 @@ def drop_token(request, room_id):
def confirm_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.RESERVED
).first()
if slot:
token_id = request.session.pop("kit_token_id", None)
token = None
if token_id:
token = request.user.tokens.filter(id=token_id).first()
if not token:
token = select_token(request.user)
if token:
debit_token(request.user, slot, token)
slot_number = request.POST.get("slot_number")
if slot_number:
# CARTE per-slot fill: directly fill the requested slot
carte = request.user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte:
slot = room.gate_slots.filter(
slot_number=slot_number, status=GateSlot.EMPTY
).first()
if slot:
debit_token(request.user, slot, carte)
# slots_claimed is the high-water mark — advance if beyond current
if int(slot_number) > carte.slots_claimed:
carte.slots_claimed = int(slot_number)
carte.save()
else:
slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.RESERVED
).first()
if slot:
token_id = request.session.pop("kit_token_id", None)
token = None
if token_id:
token = request.user.tokens.filter(id=token_id).first()
if not token:
token = select_token(request.user)
if token:
debit_token(request.user, slot, token)
return redirect("epic:gatekeeper", room_id=room_id)
@@ -121,6 +164,22 @@ def confirm_token(request, room_id):
def return_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
# CARTE full return: reset token + all CARTE-debited slots
carte = request.user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte:
room.gate_slots.filter(
debited_token_type=Token.CARTE, gamer=request.user
).update(
gamer=None, status=GateSlot.EMPTY, filled_at=None,
debited_token_type=None, debited_token_expires_at=None,
)
carte.current_room = None
carte.slots_claimed = 0
carte.save()
request.session.pop("kit_token_id", None)
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
gamer=request.user,
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
@@ -152,6 +211,29 @@ def return_token(request, room_id):
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def release_slot(request, room_id):
"""Un-fill a single CARTE-claimed slot without returning the CARTE itself."""
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot_number = request.POST.get("slot_number")
if slot_number:
slot = room.gate_slots.filter(
slot_number=slot_number,
debited_token_type=Token.CARTE,
gamer=request.user,
status=GateSlot.FILLED,
).first()
if slot:
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def invite_gamer(request, room_id):
if request.method == "POST":
@@ -192,8 +274,6 @@ def abandon_room(request, room_id):
def gate_status(request, room_id):
room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN:
return HttpResponse("")
ctx = _gate_context(room, request.user)
ctx["room"] = room
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)