From 4239245902edb2c8e92b246b556ab124bee6413a Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 16 Mar 2026 00:07:52 -0400 Subject: [PATCH] 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: 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 --- src/apps/dashboard/views.py | 6 +- src/apps/epic/models.py | 2 + src/apps/epic/tests/integrated/test_views.py | 5 +- src/apps/epic/urls.py | 1 + src/apps/epic/views.py | 118 ++++++- .../static/apps/gameboard/gameboard.js | 135 +++++++- src/apps/gameboard/urls.py | 1 + src/apps/gameboard/views.py | 22 +- .../0010_carte_blanche_token_type.py | 18 + .../0011_user_equipped_trinket_fk.py | 19 + .../migrations/0012_carte_slots_claimed.py | 18 + src/apps/lyric/models.py | 23 +- .../lyric/tests/integrated/test_models.py | 53 ++- src/apps/lyric/tests/unit/test_tokens.py | 18 + .../management/commands/create_session.py | 2 +- src/functional_tests/test_gatekeeper.py | 8 +- .../test_trinket_carte_blanche.py | 325 ++++++++++++++++++ src/static_src/scss/_gameboard.scss | 14 +- src/static_src/scss/rootvars.scss | 12 +- .../gameboard/_partials/_applet-game-kit.html | 64 ++-- .../_partials/_equip_trinket_btn.html | 1 + .../apps/gameboard/_partials/_gatekeeper.html | 17 +- src/templates/apps/gameboard/gameboard.html | 1 + src/templates/apps/gameboard/room.html | 2 +- .../_partials/_applet-wallet-tokens.html | 6 + .../core/_partials/_kit_bag_panel.html | 56 +-- 26 files changed, 842 insertions(+), 105 deletions(-) create mode 100644 src/apps/lyric/migrations/0010_carte_blanche_token_type.py create mode 100644 src/apps/lyric/migrations/0011_user_equipped_trinket_fk.py create mode 100644 src/apps/lyric/migrations/0012_carte_slots_claimed.py create mode 100644 src/functional_tests/test_trinket_carte_blanche.py create mode 100644 src/templates/apps/gameboard/_partials/_equip_trinket_btn.html diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 7da26f6..6d9408b 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -19,15 +19,15 @@ from apps.lyric.models import PaymentMethod, Token, User, Wallet APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"] UNLOCKED_PALETTES = frozenset([ "palette-default", + "palette-sepia", "palette-monochrome-light", "palette-monochrome-dark", - "palette-sepia", ]) PALETTES = [ {"name": "palette-default", "label": "Earthman", "locked": False}, + {"name": "palette-sepia", "label": "Sepia", "locked": False}, {"name": "palette-monochrome-light", "label": "Monochrome (Light)", "locked": False}, {"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "locked": False}, - {"name": "palette-sepia", "label": "Sepia", "locked": False}, {"name": "palette-nirvana", "label": "Nirvana", "locked": True}, {"name": "palette-sheol", "label": "Sheol", "locked": True}, {"name": "palette-inferno", "label": "Inferno", "locked": True}, @@ -179,7 +179,7 @@ def kit_bag(request): ) tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE] return render(request, "core/_partials/_kit_bag_panel.html", { - "tokens": tokens, + "equipped_trinket": request.user.equipped_trinket, "free_token": free_tokens[0] if free_tokens else None, "free_count": len(free_tokens), "tithe_token": tithe_tokens[0] if tithe_tokens else None, diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 9768170..8125f5f 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -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() diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index ac43315..8545199 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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( diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index a985526..bcc24cd 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path('room//gate/drop_token', views.drop_token, name='drop_token'), path('room//gate/confirm_token', views.confirm_token, name='confirm_token'), path('room//gate/return_token', views.return_token, name='return_token'), + path('room//gate/release_slot', views.release_slot, name='release_slot'), path('room//gate/invite', views.invite_gamer, name='invite_gamer'), path('room//gate/status', views.gate_status, name='gate_status'), path('room//delete', views.delete_room, name='delete_room'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index d6aeff2..cb5c287 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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) diff --git a/src/apps/gameboard/static/apps/gameboard/gameboard.js b/src/apps/gameboard/static/apps/gameboard/gameboard.js index c83665e..977db90 100644 --- a/src/apps/gameboard/static/apps/gameboard/gameboard.js +++ b/src/apps/gameboard/static/apps/gameboard/gameboard.js @@ -1,25 +1,128 @@ +function getCsrfToken() { + const match = document.cookie.match(/csrftoken=([^;]+)/) + return match ? match[1] : ''; +} + function initGameKitTooltips() { const portal = document.getElementById('id_tooltip_portal'); - if (!portal) return; + const miniPortal = document.getElementById('id_mini_tooltip_portal'); + const gameKit = document.getElementById('id_game_kit'); + if (!portal || !miniPortal || !gameKit) return; - document.querySelectorAll('#id_game_kit .token').forEach(token => { + let equippedId = gameKit.dataset.equippedId || ''; + let activeToken = null; + let equipping = false; + + function inRect(x, y, r) { + return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; + } + + function closePortals() { + portal.classList.remove('active'); + miniPortal.classList.remove('active'); + miniPortal.style.display = ''; + activeToken = null; + } + + document.addEventListener('mousemove', (e) => { + if (portal.classList.contains('active') && activeToken) { + const rects = [activeToken.getBoundingClientRect(), portal.getBoundingClientRect()]; + if (miniPortal.classList.contains('active')) rects.push(miniPortal.getBoundingClientRect()); + const left = Math.min(...rects.map(r => r.left)); + const top = Math.min(...rects.map(r => r.top)); + const right = Math.max(...rects.map(r => r.right)); + const bottom = Math.max(...rects.map(r => r.bottom)); + if (!inRect(e.clientX, e.clientY, { left, top, right, bottom })) closePortals(); + } else if (!portal.classList.contains('active')) { + for (const tokenEl of gameKit.querySelectorAll('.token')) { + if (!tokenEl.querySelector('.token-tooltip')) continue; + if (inRect(e.clientX, e.clientY, tokenEl.getBoundingClientRect())) { + showPortals(tokenEl); + break; + } + } + } + }); + + function buildMiniContent(tokenId) { + if (equippedId && tokenId === equippedId) { + miniPortal.textContent = 'Equipped'; + } else { + const btn = document.createElement('button'); + btn.className = 'equip-trinket-btn'; + btn.dataset.tokenId = tokenId; + btn.textContent = 'Equip Trinket?'; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + equipping = true; + equippedId = tokenId; + gameKit.dataset.equippedId = equippedId; + fetch(`/gameboard/equip-trinket/${tokenId}/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }).then(r => { + if (r.ok && equipping) { + equipping = false; + closePortals(); + } else { + equipping = false; + } + }); + }); + miniPortal.innerHTML = ''; + miniPortal.appendChild(btn); + } + } + + function showPortals(token) { + equipping = false; + activeToken = token; const tooltip = token.querySelector('.token-tooltip'); - if (!tooltip) return; + portal.innerHTML = tooltip.innerHTML; + portal.classList.add('active'); - token.addEventListener('mouseenter', () => { - const rect = token.getBoundingClientRect(); - portal.innerHTML = tooltip.innerHTML; - portal.classList.add('active'); - const halfW = portal.offsetWidth / 2; - const rawLeft = rect.left + rect.width / 2; - const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8)); - portal.style.left = Math.round(clampedLeft) + 'px'; - portal.style.top = Math.round(rect.top) + 'px'; - portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))'; - }); + const isEquippable = !!token.dataset.tokenId; + let miniHeight = 0; - token.addEventListener('mouseleave', () => { - portal.classList.remove('active'); + if (isEquippable) { + buildMiniContent(token.dataset.tokenId); + miniPortal.classList.add('active'); + miniPortal.style.display = 'block'; + miniHeight = miniPortal.offsetHeight + 4; + } else { + miniPortal.classList.remove('active'); + miniPortal.style.display = ''; + } + + const tokenRect = token.getBoundingClientRect(); + const halfW = portal.offsetWidth / 2; + const rawLeft = tokenRect.left + tokenRect.width / 2; + const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8)); + portal.style.left = Math.round(clampedLeft) + 'px'; + portal.style.top = Math.round(tokenRect.top) + 'px'; + portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`; + + if (isEquippable) { + const mainRect = portal.getBoundingClientRect(); + miniPortal.style.left = (mainRect.right - miniPortal.offsetWidth) + 'px'; + miniPortal.style.top = (mainRect.bottom + 4) + 'px'; + } + } + + document.addEventListener('mouseover', (e) => { + const tokenEl = e.target.closest('#id_game_kit .token'); + if (!tokenEl || !tokenEl.querySelector('.token-tooltip')) return; + if (!portal.classList.contains('active') || activeToken !== tokenEl) { + showPortals(tokenEl); + } + }); + + gameKit.querySelectorAll('.token').forEach(tokenEl => { + if (!tokenEl.querySelector('.token-tooltip')) return; + tokenEl.addEventListener('mouseenter', () => { + if (!portal.classList.contains('active') || activeToken !== tokenEl) { + showPortals(tokenEl); + } }); }); } diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index 271fbf6..f6b37b8 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -6,5 +6,6 @@ from . import views urlpatterns = [ path('', views.gameboard, name='gameboard'), path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'), + path('equip-trinket//', views.equip_trinket, name='equip_trinket'), ] diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 3c46c43..2d179c2 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -1,6 +1,7 @@ from django.contrib.auth.decorators import login_required from django.db.models import Q -from django.shortcuts import redirect, render +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from apps.applets.utils import applet_context @@ -20,6 +21,7 @@ GAMEBOARD_APPLET_ORDER = [ def gameboard(request): pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None coin = request.user.tokens.filter(token_type=Token.COIN).first() + 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")) @@ -27,6 +29,8 @@ def gameboard(request): request, "apps/gameboard/gameboard.html", { "pass_token": pass_token, "coin": coin, + "carte": carte, + "equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "free_tokens": free_tokens, "free_count": len(free_tokens), "applets": applet_context(request.user, "gameboard"), @@ -53,6 +57,8 @@ def toggle_game_applets(request): "applets": applet_context(request.user, "gameboard"), "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "coin": request.user.tokens.filter(token_type=Token.COIN).first(), + "carte": request.user.tokens.filter(token_type=Token.CARTE).first(), + "equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "free_tokens": list(request.user.tokens.filter( token_type=Token.FREE, expires_at__gt=timezone.now() ).order_by("expires_at")), @@ -66,3 +72,17 @@ def toggle_game_applets(request): ).distinct(), }) return redirect("gameboard") + + +@login_required(login_url="/") +def equip_trinket(request, token_id): + token = get_object_or_404(Token, pk=token_id, user=request.user) + if request.method == "POST": + request.user.equipped_trinket = token + request.user.save(update_fields=["equipped_trinket"]) + return HttpResponse(status=204) + return render( + request, + "apps/gameboard/_partials/_equip_trinket_btn.html", + {"token": token}, + ) diff --git a/src/apps/lyric/migrations/0010_carte_blanche_token_type.py b/src/apps/lyric/migrations/0010_carte_blanche_token_type.py new file mode 100644 index 0000000..1b9b5ff --- /dev/null +++ b/src/apps/lyric/migrations/0010_carte_blanche_token_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-03-15 23:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0009_alter_token_token_type'), + ] + + operations = [ + migrations.AlterField( + model_name='token', + name='token_type', + field=models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass'), ('carte', 'Carte Blanche')], max_length=8), + ), + ] diff --git a/src/apps/lyric/migrations/0011_user_equipped_trinket_fk.py b/src/apps/lyric/migrations/0011_user_equipped_trinket_fk.py new file mode 100644 index 0000000..20dcd83 --- /dev/null +++ b/src/apps/lyric/migrations/0011_user_equipped_trinket_fk.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0 on 2026-03-15 23:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0010_carte_blanche_token_type'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='equipped_trinket', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='lyric.token'), + ), + ] diff --git a/src/apps/lyric/migrations/0012_carte_slots_claimed.py b/src/apps/lyric/migrations/0012_carte_slots_claimed.py new file mode 100644 index 0000000..396244d --- /dev/null +++ b/src/apps/lyric/migrations/0012_carte_slots_claimed.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-03-16 03:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0011_user_equipped_trinket_fk'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='slots_claimed', + field=models.PositiveSmallIntegerField(default=0), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index a13c26b..4b34a4b 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -33,6 +33,10 @@ class User(AbstractBaseUser): searchable = models.BooleanField(default=False) palette = models.CharField(max_length=32, default="palette-default") stripe_customer_id = models.CharField(max_length=255, null=True, blank=True) + equipped_trinket = models.ForeignKey( + "Token", null=True, blank=True, + on_delete=models.SET_NULL, related_name="+", + ) is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) @@ -72,11 +76,13 @@ class Token(models.Model): FREE = "Free" TITHE = "tithe" PASS = "pass" + CARTE = "carte" TOKEN_TYPE_CHOICES = [ (COIN, "Coin-on-a-String"), (FREE, "Free Token"), (TITHE, "Tithe Token"), (PASS, "Backstage Pass"), + (CARTE, "Carte Blanche"), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens") @@ -87,6 +93,7 @@ class Token(models.Model): on_delete=models.SET_NULL, related_name="coin_tokens" ) next_ready_at = models.DateTimeField(null=True, blank=True) + slots_claimed = models.PositiveSmallIntegerField(default=0, blank=True) def tooltip_name(self): return self.get_token_type_display() @@ -98,10 +105,12 @@ class Token(models.Model): return "Admit All Entry" if self.token_type == self.TITHE: return "+ Writ bonus" + if self.token_type == self.CARTE: + return "Admit up to +6" return "" def tooltip_expiry(self): - if self.token_type in (self.COIN, self.PASS): + if self.token_type in (self.COIN, self.PASS, self.CARTE): if self.token_type == self.COIN and self.next_ready_at: return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}" return "no expiry" @@ -118,8 +127,12 @@ class Token(models.Model): def tooltip_shoptalk(self): if self.token_type == self.COIN: return "\u2026and another after that, and another after that\u2026" + if self.token_type == self.FREE: + return "a spot of good fortune" if self.token_type == self.PASS: return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?" + if self.token_type == self.CARTE: + return "No, I\u2019m afraid we\u2019ll be taking over from here." return None def tooltip_text(self): @@ -143,11 +156,15 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs): if not created: return Wallet.objects.create(user=instance, writs=144) - Token.objects.create(user=instance, token_type=Token.COIN) + coin = Token.objects.create(user=instance, token_type=Token.COIN) Token.objects.create( user=instance, token_type=Token.FREE, expires_at=timezone.now() + timedelta(days=7), ) if instance.is_staff: - Token.objects.create(user=instance, token_type=Token.PASS) + pass_token = Token.objects.create(user=instance, token_type=Token.PASS) + instance.equipped_trinket = pass_token + else: + instance.equipped_trinket = coin + instance.save(update_fields=['equipped_trinket']) diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index 98971bc..c7077eb 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -163,15 +163,64 @@ class TokenTooltipTest(TestCase): free.expires_at = None self.assertEqual(free.tooltip_expiry(), "") - def test_tooltip_shoptalk_none_for_non_coin(self): + def test_tooltip_shoptalk_none_for_free_coin(self): free = Token.objects.get(user=self.user, token_type=Token.FREE) - self.assertIsNone(free.tooltip_shoptalk()) + self.assertEqual(free.tooltip_shoptalk(), "a spot of good fortune") def test_tooltip_room_html_returns_empty_when_no_room(self): token = Token.objects.get(user=self.user, token_type=Token.COIN) self.assertEqual(token.tooltip_room_html(), "") +class EquippedTrinketTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="equip@test.io", is_staff=True) + self.pass_token = self.user.tokens.get(token_type=Token.PASS) + + def test_normal_user_equipped_trinket_defaults_to_coin(self): + user = User.objects.create(email="noequip@test.io") + coin = user.tokens.get(token_type=Token.COIN) + self.assertEqual(user.equipped_trinket, coin) + + def test_staff_user_equipped_trinket_defaults_to_pass(self): + self.assertEqual(self.user.equipped_trinket, self.pass_token) + + def test_equipped_trinket_can_be_set_to_pass(self): + self.user.equipped_trinket = self.pass_token + self.user.save(update_fields=["equipped_trinket"]) + self.assertEqual( + User.objects.get(pk=self.user.pk).equipped_trinket, self.pass_token + ) + + def test_equipped_trinket_can_be_set_to_carte(self): + carte = Token.objects.create(user=self.user, token_type=Token.CARTE) + self.user.equipped_trinket = carte + self.user.save(update_fields=["equipped_trinket"]) + self.assertEqual( + User.objects.get(pk=self.user.pk).equipped_trinket, carte + ) + + def test_equipped_trinket_can_be_cleared(self): + self.user.equipped_trinket = self.pass_token + self.user.save(update_fields=["equipped_trinket"]) + self.user.equipped_trinket = None + self.user.save(update_fields=["equipped_trinket"]) + self.assertIsNone(User.objects.get(pk=self.user.pk).equipped_trinket) + + +class CarteTokenCreationTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="carte@test.io") + + def test_carte_token_can_be_created(self): + token = Token.objects.create(user=self.user, token_type=Token.CARTE) + self.assertEqual(Token.objects.get(pk=token.pk).token_type, Token.CARTE) + + def test_carte_has_no_expiry(self): + token = Token.objects.create(user=self.user, token_type=Token.CARTE) + self.assertIsNone(token.expires_at) + + class PaymentMethodTest(TestCase): def setUp(self): self.user = User.objects.create(email="pay@test.io") diff --git a/src/apps/lyric/tests/unit/test_tokens.py b/src/apps/lyric/tests/unit/test_tokens.py index 5d33bed..0cc8f20 100644 --- a/src/apps/lyric/tests/unit/test_tokens.py +++ b/src/apps/lyric/tests/unit/test_tokens.py @@ -55,3 +55,21 @@ class PassTokenTooltipTest(SimpleTestCase): def test_tooltip_contains_no_expiry(self): self.assertIn("no expiry", self.token.tooltip_text()) + +class CarteTooltipTest(SimpleTestCase): + def setUp(self): + self.token = Token() + self.token.token_type = Token.CARTE + self.token.expires_at = None + + def test_tooltip_contains_name(self): + self.assertIn("Carte Blanche", self.token.tooltip_text()) + + def test_tooltip_contains_entry(self): + self.assertIn("Admit up to +6", self.token.tooltip_text()) + + def test_tooltip_contains_shoptalk(self): + self.assertIn("taking over from here", self.token.tooltip_text()) + + def test_tooltip_contains_no_expiry(self): + self.assertIn("no expiry", self.token.tooltip_text()) diff --git a/src/functional_tests/management/commands/create_session.py b/src/functional_tests/management/commands/create_session.py index b6cd48c..b35449f 100644 --- a/src/functional_tests/management/commands/create_session.py +++ b/src/functional_tests/management/commands/create_session.py @@ -20,7 +20,7 @@ class Command(BaseCommand): self.stdout.write(session_key) def create_pre_authenticated_session(email): - user = User.objects.create_user(email=email) + user, _ = User.objects.get_or_create(email=email) session = SessionStore() session[SESSION_KEY] = str(user.pk) # Convert UUID to string for JSON serialization session[BACKEND_SESSION_KEY] = "apps.lyric.authentication.PasswordlessAuthenticationBackend" diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index a6cfc77..a411aa0 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -190,11 +190,9 @@ class GatekeeperTest(FunctionalTest): room.refresh_from_db() room.gate_status = Room.OPEN room.save() - # 4. Gatekeeper disappears via htmx + # 4. Gate shows launch button via htmx when all slots filled self.wait_for( - lambda: self.assertEqual( - len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-modal")), 0 - ) + lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn") ) def test_owner_can_delete_room_via_gear_menu(self): @@ -573,6 +571,8 @@ class GameKitInsertTest(FunctionalTest): self.gamer.is_staff = True self.gamer.save() pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS) + self.gamer.equipped_trinket = pass_token + self.gamer.save(update_fields=["equipped_trinket"]) self.browser.get(self.gate_url) self._select_token_from_kit(pass_token) self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click() diff --git a/src/functional_tests/test_trinket_carte_blanche.py b/src/functional_tests/test_trinket_carte_blanche.py new file mode 100644 index 0000000..1a00a88 --- /dev/null +++ b/src/functional_tests/test_trinket_carte_blanche.py @@ -0,0 +1,325 @@ +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By + +from .base import FunctionalTest +from apps.applets.models import Applet +from apps.lyric.models import Token, User + + +class CarteBlancheTest(FunctionalTest): + + def setUp(self): + super().setUp() + for slug, name, cols, rows in [ + ("new-game", "New Game", 6, 3), + ("my-games", "My Games", 6, 3), + ("game-kit", "Game Kit", 6, 3), + ]: + Applet.objects.get_or_create( + slug=slug, + defaults={ + "name": name, "grid_cols": cols, + "grid_rows": rows, "context": "gameboard", + }, + ) + # is_staff triggers COIN + FREE + PASS via post_save signal; PASS auto-equipped + self.gamer = User.objects.create(email="blanche@test.io", is_staff=True) + Token.objects.create(user=self.gamer, token_type=Token.TITHE) + self.carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE) + + # ── Test 1 ─────────────────────────────────────────────────────────────── + + def test_equipped_pass_shows_mini_tooltip_in_game_kit(self): + # 1. Log in, land on dashboard + self.create_pre_authenticated_session("blanche@test.io") + self.browser.get(self.live_server_url) + # 2. Open kit bag — Backstage Pass visible in Trinkets section + self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn")).click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + f'#id_kit_bag_dialog [data-token-type="{Token.PASS}"]', + ) + ) + # 3. Navigate to gameboard + self.browser.find_element( + By.CSS_SELECTOR, '#id_footer_nav a[href="/gameboard/"]' + ).click() + self.wait_for( + lambda: self.assertRegex(self.browser.current_url, r"/gameboard/$") + ) + # 4. Find Backstage Pass in the Game Kit applet + self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) + pass_el = self.browser.find_element(By.ID, "id_kit_pass") + self.browser.execute_script( + "arguments[0].scrollIntoView({block: 'center'})", pass_el + ) + # 5. Hover over Pass — main tooltip appears via portal + ActionChains(self.browser).move_to_element(pass_el).perform() + self.wait_for( + lambda: self.assertTrue( + self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + ) + portal = self.browser.find_element(By.ID, "id_tooltip_portal") + self.assertIn("Backstage Pass", portal.text) + self.assertIn("Admit All Entry", portal.text) + # 6. A mini tooltip appears below the main tooltip, flush with its right edge. + # Since Pass is the equipped trinket, it says "Equipped." + mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") + self.wait_for(lambda: self.assertTrue(mini.is_displayed())) + self.assertIn("Equipped", mini.text) + portal_rect = self.browser.execute_script( + "return arguments[0].getBoundingClientRect()", portal + ) + mini_rect = self.browser.execute_script( + "return arguments[0].getBoundingClientRect()", mini + ) + self.assertGreater(mini_rect["top"], portal_rect["bottom"] - 5) # below main + self.assertAlmostEqual(mini_rect["right"], portal_rect["right"], delta=10) # flush right + + # ── Test 2 ─────────────────────────────────────────────────────────────── + + def test_carte_blanche_equip_and_multi_slot_gatekeeper(self): + # 1. Log in, navigate directly to gameboard + self.create_pre_authenticated_session("blanche@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) + + # 2. Hover over Coin-on-a-String, Free Token — no mini tooltip (not equippable) + for token_id in ("id_kit_coin_on_a_string", "id_kit_free_token"): + el = self.browser.find_element(By.ID, token_id) + ActionChains(self.browser).move_to_element(el).perform() + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + self.assertFalse( + self.browser.find_element(By.ID, "id_mini_tooltip_portal").is_displayed() + ) + + # 3. Hover Carte Blanche — main tooltip present; mini tooltip shows "Equip Trinket?" + carte_el = self.browser.find_element(By.ID, "id_kit_carte_blanche") + self.browser.execute_script( + "arguments[0].scrollIntoView({block: 'center'})", carte_el + ) + ActionChains(self.browser).move_to_element(carte_el).perform() + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + portal = self.browser.find_element(By.ID, "id_tooltip_portal") + self.assertIn("Carte Blanche", portal.text) + self.assertIn("Admit up to +6", portal.text) + self.assertIn("taking over from here", portal.text) + self.assertIn("no expiry", portal.text) + mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") + self.wait_for(lambda: self.assertTrue(mini.is_displayed())) + equip_btn = mini.find_element(By.CSS_SELECTOR, ".equip-trinket-btn") + self.assertEqual(equip_btn.text, "Equip Trinket?") + + # 4. Click "Equip Trinket?" — DB switches; both portals close + equip_btn.click() + self.wait_for( + lambda: self.assertFalse( + self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + ) + + # 5. JS-side optimistic update: data-equipped-id reflects Carte immediately + game_kit = self.browser.find_element(By.ID, "id_game_kit") + self.wait_for( + lambda: self.assertEqual( + game_kit.get_attribute("data-equipped-id"), + str(self.carte.pk), + ) + ) + # NOTE: re-hovering carte_el here to assert "Equipped" in mini is unreliable in + # headless GeckoDriver — move_to_element uses a different scroll-into-view algorithm + # than scrollIntoView({block:'center'}), so the computed element centre can match the + # cursor's current position and no mousemove fires. The equip round-trip is validated + # implicitly by the DB-side check below (step 6: Pass now shows "Equip Trinket?"). + + # 6. Hover Backstage Pass — mini tooltip shows "Equip Trinket?" (Pass no longer equipped) + pass_el = self.browser.find_element(By.ID, "id_kit_pass") + ActionChains(self.browser).move_to_element(pass_el).perform() + self.wait_for( + lambda: self.assertIn( + "Backstage Pass", + self.browser.find_element(By.ID, "id_tooltip_portal").text, + ) + ) + mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") + self.wait_for(lambda: self.assertTrue(mini.is_displayed())) + self.assertTrue(mini.find_element(By.CSS_SELECTOR, ".equip-trinket-btn").is_displayed()) + + # ── GATEKEEPER PHASE ───────────────────────────────────────────────── + + # 8. Create a new game room via the New Game applet + self.browser.find_element(By.ID, "id_new_game_name").send_keys("The Long Room") + self.browser.find_element(By.ID, "id_create_game_btn").click() + self.wait_for( + lambda: self.assertIn("/gameboard/room/", self.browser.current_url) + ) + self.wait_for( + lambda: self.assertIn("/gate/", self.browser.current_url) + ) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + ) + + def open_kit_and_select_carte(): + self.browser.find_element(By.ID, "id_kit_btn").click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]', + ) + ) + self.browser.find_element( + By.CSS_SELECTOR, + f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]', + ).click() + + def deposit_carte(): + self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed") + ) + + # 9. Open kit bag, select Carte Blanche, deposit via rails btn + open_kit_and_select_carte() + deposit_carte() + + # Helper: always fetches a fresh gate-slot element (avoid stale refs after redirects) + def get_circle(i): + return self.browser.find_element( + By.CSS_SELECTOR, f".gate-slot[data-slot='{i + 1}']" + ) + + # 10. Return panel glows; circle 1 gains OK btn → click it → fills + return_panel = self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed") + self.assertTrue(return_panel.is_displayed()) + self.wait_for( + lambda: get_circle(0).find_element(By.CSS_SELECTOR, ".drop-token-btn") + ).click() + self.wait_for( + lambda: self.assertIn("filled", get_circle(0).get_attribute("class")) + ) + + # 11. Return panel still glows; circle 2 now has OK btn + self.assertTrue( + self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed").is_displayed() + ) + self.wait_for(lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn")) + + # 12. Carte tooltip in kit bag shows room name (lease info) + self.browser.find_element(By.ID, "id_kit_btn").click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]', + ) + ) + carte_in_bag = self.browser.find_element( + By.CSS_SELECTOR, f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]' + ) + # Kit bag tooltips are CSS-hidden; read textContent (not .text) to avoid + # relying on hover visibility in headless Firefox. + self.assertIn( + "The Long Room", + carte_in_bag.find_element(By.CSS_SELECTOR, ".token-tooltip").get_attribute("textContent"), + ) + # Close kit bag + self.browser.find_element(By.ID, "id_kit_btn").click() + self.wait_for( + lambda: self.assertFalse( + self.browser.find_element(By.ID, "id_kit_bag_dialog").is_displayed() + ) + ) + + # 13. Cold feet: click return panel → Carte returned, return panel gone + self.browser.find_element(By.CSS_SELECTOR, ".token-return-btn").click() + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".token-slot.claimed")), 0 + ) + ) + # Lease info cleared from kit bag tooltip + self.browser.find_element(By.ID, "id_kit_btn").click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]', + ) + ) + carte_in_bag = self.browser.find_element( + By.CSS_SELECTOR, f'#id_kit_bag_dialog [data-token-type="{Token.CARTE}"]' + ) + self.assertNotIn( + "The Long Room", + carte_in_bag.find_element(By.CSS_SELECTOR, ".token-tooltip").get_attribute("textContent"), + ) + self.browser.find_element(By.ID, "id_kit_btn").click() + + # ── COLD FEET RESOLVED: full six-slot run ──────────────────────────── + + # 14. Re-deposit Carte + open_kit_and_select_carte() + deposit_carte() + + # 15. Click OK on circle 1 → fills; circle 1 loses its OK btn; circle 2 gains one + self.wait_for( + lambda: get_circle(0).find_element(By.CSS_SELECTOR, ".drop-token-btn") + ).click() + self.wait_for( + lambda: self.assertIn("filled", get_circle(0).get_attribute("class")) + ) + self.wait_for( + lambda: self.assertEqual( + len(get_circle(0).find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0 + ) + ) + self.wait_for(lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn")) + + # 16. Click OK on circle 2 → turns to NVM; circle 3 gains OK + self.wait_for( + lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn") + ).click() + self.wait_for( + lambda: self.assertEqual( + get_circle(1).find_element(By.CSS_SELECTOR, ".slot-release-btn").text.upper(), + "NVM", + ) + ) + self.wait_for(lambda: get_circle(2).find_element(By.CSS_SELECTOR, ".drop-token-btn")) + + # 17. Click NVM on circle 2 → returns to OK; circle 3 still has OK + # Circle 1 still filled; return panel still glowing; lease name still present + self.wait_for( + lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".slot-release-btn") + ).click() + self.wait_for(lambda: get_circle(1).find_element(By.CSS_SELECTOR, ".drop-token-btn")) + self.assertIn("filled", get_circle(0).get_attribute("class")) + self.assertTrue( + self.browser.find_element(By.CSS_SELECTOR, ".token-slot.claimed").is_displayed() + ) + self.wait_for(lambda: get_circle(2).find_element(By.CSS_SELECTOR, ".drop-token-btn")) + + # 18. Fill circles 2 → 6 sequentially; verify each subsequent OK appears + for i in range(1, 6): + self.wait_for( + lambda i=i: get_circle(i).find_element(By.CSS_SELECTOR, ".drop-token-btn") + ).click() + self.wait_for( + lambda i=i: self.assertIn("filled", get_circle(i).get_attribute("class")) + ) + if i < 5: + self.wait_for( + lambda i=i: get_circle(i + 1).find_element( + By.CSS_SELECTOR, ".drop-token-btn" + ) + ) + + # 19. All six circles filled — launch btn appears + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn") + ) diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 39caa7a..612a132 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -6,7 +6,7 @@ body.page-gameboard { overflow: hidden; .container { - overflow: hidden; + overflow: clip; display: flex; flex-direction: column; flex: 1; @@ -109,6 +109,18 @@ body.page-gameboard { &.active { display: block; } } +#id_mini_tooltip_portal { + position: fixed; + z-index: 9999; + font-size: 0.8em; + font-style: italic; + width: fit-content; + white-space: nowrap; + text-align: right; + + &.active { display: block; } +} + @media (max-height: 500px) { body.page-gameboard { .container { diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index cf7eda1..5588fa5 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -395,16 +395,16 @@ } /* Monochrome Light Palette */ .palette-monochrome-light { - --priUser: var(--sixAg); /* 240,240,240 — light gray bg */ - --secUser: var(--terAg); /* 100,100,100 — mid-dark text/border */ - --terUser: var(--secAg); /* 60,60,60 — dark accent */ + --priUser: var(--sixAdm); /* 240,240,240 — light gray bg */ + --secUser: var(--terNi); /* 100,100,100 — mid-dark text/border */ + --terUser: var(--priPer); /* 60,60,60 — dark accent */ --quaUser: var(--priAg); /* 30,30,30 — near-black active */ - --quiUser: var(--quaAg); /* 133,133,133 — mid-gray action */ + --quiUser: var(--sixAdm); /* 133,133,133 — mid-gray action */ --sixUser: var(--quiAg); /* 175,175,175 — subtle */ --sepUser: var(--sixAg); /* 240,240,240 — secondary subtle */ --octUser: var(--terNi); /* 93,95,94 — links */ - --ninUser: var(--priCtn); /* 255,251,246 — warm bright highlight */ - --decUser: var(--secPt); /* 189,190,189 — light mid */ + --ninUser: var(--terPer); /* 255,251,246 — warm bright highlight */ + --decUser: var(--terPt); /* 189,190,189 — light mid */ } /* Sepia Palette */ .palette-sepia { diff --git a/src/templates/apps/gameboard/_partials/_applet-game-kit.html b/src/templates/apps/gameboard/_partials/_applet-game-kit.html index 2d86d61..50afb9e 100644 --- a/src/templates/apps/gameboard/_partials/_applet-game-kit.html +++ b/src/templates/apps/gameboard/_partials/_applet-game-kit.html @@ -3,32 +3,49 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

Game Kit

-
+
{% if pass_token %} -
+
-

{{ pass_token.tooltip_name }}

-

{{ pass_token.tooltip_description }}

- {% if pass_token.tooltip_shoptalk %} - {{ pass_token.tooltip_shoptalk }} - {% endif %} -

{{ pass_token.tooltip_expiry }}

+
+

{{ pass_token.tooltip_name }}

+

{{ pass_token.tooltip_description }}

+ {% if pass_token.tooltip_shoptalk %} + {{ pass_token.tooltip_shoptalk }} + {% endif %} +

{{ pass_token.tooltip_expiry }}

+
+
+
+ {% endif %} + {% if carte %} +
+ +
+
+

{{ carte.tooltip_name }}

+

{{ carte.tooltip_description }}

+ {% if carte.tooltip_shoptalk %} + {{ carte.tooltip_shoptalk }} + {% endif %} +

{{ carte.tooltip_expiry }}

+
{% endif %} {% if coin %} -
+
-

{{ coin.tooltip_name }}

-

- {{ coin.tooltip_description }} -

- {% if coin.tooltip_shoptalk %} - {{ coin.tooltip_shoptalk }} - {% endif %} -

{{ coin.tooltip_expiry }}

+
+

{{ coin.tooltip_name }}

+

{{ coin.tooltip_description }}

+ {% if coin.tooltip_shoptalk %} + {{ coin.tooltip_shoptalk }} + {% endif %} +

{{ coin.tooltip_expiry }}

+
{% endif %} @@ -37,9 +54,14 @@
-

{{ token.tooltip_name }}{% if free_count > 1 %} (×{{ free_count }}){% endif %}

-

{{ token.tooltip_description }}

-

{{ token.tooltip_expiry }}

+
+

{{ token.tooltip_name }}{% if free_count > 1 %} (×{{ free_count }}){% endif %}

+

{{ token.tooltip_description }}

+ {% if token.tooltip_shoptalk %} + {{ token.tooltip_shoptalk }} + {% endif %} +

{{ token.tooltip_expiry }}

+
{% endwith %} @@ -47,4 +69,4 @@
- \ No newline at end of file + diff --git a/src/templates/apps/gameboard/_partials/_equip_trinket_btn.html b/src/templates/apps/gameboard/_partials/_equip_trinket_btn.html new file mode 100644 index 0000000..6a9e3aa --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_equip_trinket_btn.html @@ -0,0 +1 @@ + diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index a3e7953..ab93a9f 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -17,7 +17,7 @@
-
+
{% if can_drop %}
{% csrf_token %} @@ -66,10 +66,25 @@ {% endif %}
+ {% elif carte_active and slot.status == 'EMPTY' and slot.slot_number <= carte_slots_claimed|add:1 %} +
+ {% csrf_token %} + + +
+ {% elif carte_active and slot.status == 'FILLED' and slot.slot_number == carte_nvm_slot_number %} +
+ {% csrf_token %} + + +
{% endif %}
{% endfor %}
+ {% if room.gate_status == 'OPEN' %} + + {% endif %} {% if request.user == room.owner %}
diff --git a/src/templates/apps/gameboard/gameboard.html b/src/templates/apps/gameboard/gameboard.html index 3c023ac..46493f8 100644 --- a/src/templates/apps/gameboard/gameboard.html +++ b/src/templates/apps/gameboard/gameboard.html @@ -10,6 +10,7 @@ {% include "apps/gameboard/_partials/_applets.html" %}
+
{% endblock content %} {% block scripts %} diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index cff3d52..c4c1b60 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -10,7 +10,7 @@
- {% if room.gate_status == "GATHERING" %} + {% if room.gate_status == "GATHERING" or room.gate_status == "OPEN" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %} {% endif %} {% include "apps/gameboard/_partials/_room_gear.html" %} diff --git a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html index 7881f5d..02e4ff5 100644 --- a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html +++ b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html @@ -10,6 +10,9 @@

{{ pass_token.tooltip_name }}

{{ pass_token.tooltip_description }}

+ {% if pass_token.tooltip_shoptalk %} + {{ pass_token.tooltip_shoptalk }} + {% endif %}

{{ pass_token.tooltip_expiry }}

@@ -33,6 +36,9 @@

{{ token.tooltip_name }}{% if free_count > 1 %} (×{{ free_count }}){% endif %}

{{ token.tooltip_description }}

+ {% if token.tooltip_shoptalk %} + {{ token.tooltip_shoptalk }} + {% endif %}

{{ token.tooltip_expiry }}

diff --git a/src/templates/core/_partials/_kit_bag_panel.html b/src/templates/core/_partials/_kit_bag_panel.html index 2b53b3c..104a0f0 100644 --- a/src/templates/core/_partials/_kit_bag_panel.html +++ b/src/templates/core/_partials/_kit_bag_panel.html @@ -1,31 +1,36 @@ -{% if tokens %} +{% if equipped_trinket or free_token or tithe_token %}
Trinkets
- {% for token in tokens %} - {% if token.token_type == "coin" or token.token_type == "pass" %} -
- {% if token.token_type == "coin" %} - - {% else %} - - {% endif %} -
-

{{ token.tooltip_name }}

-

{{ token.tooltip_description }}

- {% if token.tooltip_shoptalk %} - {{ token.tooltip_shoptalk }} - {% endif %} -

{{ token.tooltip_expiry }}

-
-
+ {% if equipped_trinket %} + {% with token=equipped_trinket %} +
+ {% if token.token_type == "coin" %} + + {% elif token.token_type == "carte" %} + + {% else %} + {% endif %} - {% endfor %} +
+

{{ token.tooltip_name }}

+

{{ token.tooltip_description }}

+ {% if token.tooltip_shoptalk %} + {{ token.tooltip_shoptalk }} + {% endif %} +

{{ token.tooltip_expiry }}

+ {% with room_html=token.tooltip_room_html %} + {% if room_html %}{{ room_html|safe }}{% endif %} + {% endwith %} +
+
+ {% endwith %} + {% endif %}
@@ -42,6 +47,9 @@

{{ free_token.tooltip_name }}{% if free_count > 1 %} (×{{ free_count }}){% endif %}

{{ free_token.tooltip_description }}

+ {% if free_token.tooltip_shoptalk %} + {{ free_token.tooltip_shoptalk }} + {% endif %}

{{ free_token.tooltip_expiry }}