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
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:
@@ -19,15 +19,15 @@ from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
|||||||
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
||||||
UNLOCKED_PALETTES = frozenset([
|
UNLOCKED_PALETTES = frozenset([
|
||||||
"palette-default",
|
"palette-default",
|
||||||
|
"palette-sepia",
|
||||||
"palette-monochrome-light",
|
"palette-monochrome-light",
|
||||||
"palette-monochrome-dark",
|
"palette-monochrome-dark",
|
||||||
"palette-sepia",
|
|
||||||
])
|
])
|
||||||
PALETTES = [
|
PALETTES = [
|
||||||
{"name": "palette-default", "label": "Earthman", "locked": False},
|
{"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-light", "label": "Monochrome (Light)", "locked": False},
|
||||||
{"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "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-nirvana", "label": "Nirvana", "locked": True},
|
||||||
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||||
{"name": "palette-inferno", "label": "Inferno", "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]
|
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
||||||
return render(request, "core/_partials/_kit_bag_panel.html", {
|
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_token": free_tokens[0] if free_tokens else None,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
|
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ def debit_token(user, slot, token):
|
|||||||
period = slot.room.renewal_period or timedelta(days=7)
|
period = slot.room.renewal_period or timedelta(days=7)
|
||||||
token.next_ready_at = timezone.now() + period
|
token.next_ready_at = timezone.now() + period
|
||||||
token.save()
|
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:
|
elif token.token_type != Token.PASS:
|
||||||
slot.debited_token_expires_at = token.expires_at
|
slot.debited_token_expires_at = token.expires_at
|
||||||
token.delete()
|
token.delete()
|
||||||
|
|||||||
@@ -64,11 +64,12 @@ class GateStatusViewTest(TestCase):
|
|||||||
self.client.force_login(self.owner)
|
self.client.force_login(self.owner)
|
||||||
self.room = Room.objects.create(name="Test Room", owner=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.gate_status = Room.OPEN
|
||||||
self.room.save()
|
self.room.save()
|
||||||
response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
|
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):
|
def test_gate_status_returns_partial_when_gathering(self):
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
|
|||||||
@@ -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/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/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/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/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>/gate/status', views.gate_status, name='gate_status'),
|
||||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||||
|
|||||||
@@ -26,14 +26,30 @@ def _gate_context(room, user):
|
|||||||
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
|
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
|
||||||
user_reserved_slot = None
|
user_reserved_slot = None
|
||||||
user_filled_slot = None
|
user_filled_slot = None
|
||||||
|
carte_token = None
|
||||||
|
carte_slots_claimed = 0
|
||||||
|
carte_nvm_slot_number = None
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
|
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
|
||||||
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).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 = (
|
eligible = (
|
||||||
user.is_authenticated
|
user.is_authenticated
|
||||||
and pending_slot is None
|
and pending_slot is None
|
||||||
and user_reserved_slot is None
|
and user_reserved_slot is None
|
||||||
and user_filled_slot is None
|
and user_filled_slot is None
|
||||||
|
and not carte_active
|
||||||
)
|
)
|
||||||
token_depleted = eligible and select_token(user) is None
|
token_depleted = eligible and select_token(user) is None
|
||||||
can_drop = eligible and not token_depleted
|
can_drop = eligible and not token_depleted
|
||||||
@@ -41,7 +57,7 @@ def _gate_context(room, user):
|
|||||||
user_reserved_slot is not None
|
user_reserved_slot is not None
|
||||||
and slots.filter(status=GateSlot.EMPTY).count() == 0
|
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 {
|
return {
|
||||||
"slots": slots,
|
"slots": slots,
|
||||||
"pending_slot": pending_slot,
|
"pending_slot": pending_slot,
|
||||||
@@ -51,6 +67,9 @@ def _gate_context(room, user):
|
|||||||
"token_depleted": token_depleted,
|
"token_depleted": token_depleted,
|
||||||
"is_last_slot": is_last_slot,
|
"is_last_slot": is_last_slot,
|
||||||
"user_can_reject": user_can_reject,
|
"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):
|
def drop_token(request, room_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
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")
|
token_id = request.POST.get("token_id")
|
||||||
if token_id:
|
if token_id:
|
||||||
token = request.user.tokens.filter(id=token_id).first()
|
token = request.user.tokens.filter(id=token_id).first()
|
||||||
@@ -86,6 +101,17 @@ def drop_token(request, room_id):
|
|||||||
token = select_token(request.user)
|
token = select_token(request.user)
|
||||||
if token is None:
|
if token is None:
|
||||||
return HttpResponse(status=402)
|
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(
|
slot = room.gate_slots.filter(
|
||||||
status=GateSlot.EMPTY
|
status=GateSlot.EMPTY
|
||||||
).order_by("slot_number").first()
|
).order_by("slot_number").first()
|
||||||
@@ -102,18 +128,35 @@ def drop_token(request, room_id):
|
|||||||
def confirm_token(request, room_id):
|
def confirm_token(request, room_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
slot = room.gate_slots.filter(
|
slot_number = request.POST.get("slot_number")
|
||||||
gamer=request.user, status=GateSlot.RESERVED
|
if slot_number:
|
||||||
).first()
|
# CARTE per-slot fill: directly fill the requested slot
|
||||||
if slot:
|
carte = request.user.tokens.filter(
|
||||||
token_id = request.session.pop("kit_token_id", None)
|
token_type=Token.CARTE, current_room=room
|
||||||
token = None
|
).first()
|
||||||
if token_id:
|
if carte:
|
||||||
token = request.user.tokens.filter(id=token_id).first()
|
slot = room.gate_slots.filter(
|
||||||
if not token:
|
slot_number=slot_number, status=GateSlot.EMPTY
|
||||||
token = select_token(request.user)
|
).first()
|
||||||
if token:
|
if slot:
|
||||||
debit_token(request.user, slot, token)
|
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)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +164,22 @@ def confirm_token(request, room_id):
|
|||||||
def return_token(request, room_id):
|
def return_token(request, room_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
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(
|
slot = room.gate_slots.filter(
|
||||||
gamer=request.user,
|
gamer=request.user,
|
||||||
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
|
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
|
||||||
@@ -152,6 +211,29 @@ def return_token(request, room_id):
|
|||||||
return redirect("epic:gatekeeper", room_id=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
|
@login_required
|
||||||
def invite_gamer(request, room_id):
|
def invite_gamer(request, room_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -192,8 +274,6 @@ def abandon_room(request, room_id):
|
|||||||
|
|
||||||
def gate_status(request, room_id):
|
def gate_status(request, room_id):
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.gate_status == Room.OPEN:
|
|
||||||
return HttpResponse("")
|
|
||||||
ctx = _gate_context(room, request.user)
|
ctx = _gate_context(room, request.user)
|
||||||
ctx["room"] = room
|
ctx["room"] = room
|
||||||
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
||||||
|
|||||||
@@ -1,25 +1,128 @@
|
|||||||
|
function getCsrfToken() {
|
||||||
|
const match = document.cookie.match(/csrftoken=([^;]+)/)
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
function initGameKitTooltips() {
|
function initGameKitTooltips() {
|
||||||
const portal = document.getElementById('id_tooltip_portal');
|
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');
|
const tooltip = token.querySelector('.token-tooltip');
|
||||||
if (!tooltip) return;
|
portal.innerHTML = tooltip.innerHTML;
|
||||||
|
portal.classList.add('active');
|
||||||
|
|
||||||
token.addEventListener('mouseenter', () => {
|
const isEquippable = !!token.dataset.tokenId;
|
||||||
const rect = token.getBoundingClientRect();
|
let miniHeight = 0;
|
||||||
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))';
|
|
||||||
});
|
|
||||||
|
|
||||||
token.addEventListener('mouseleave', () => {
|
if (isEquippable) {
|
||||||
portal.classList.remove('active');
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ from . import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.gameboard, name='gameboard'),
|
path('', views.gameboard, name='gameboard'),
|
||||||
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
|
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
|
||||||
|
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Q
|
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 django.utils import timezone
|
||||||
|
|
||||||
from apps.applets.utils import applet_context
|
from apps.applets.utils import applet_context
|
||||||
@@ -20,6 +21,7 @@ GAMEBOARD_APPLET_ORDER = [
|
|||||||
def gameboard(request):
|
def gameboard(request):
|
||||||
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
|
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()
|
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(
|
free_tokens = list(request.user.tokens.filter(
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
).order_by("expires_at"))
|
).order_by("expires_at"))
|
||||||
@@ -27,6 +29,8 @@ def gameboard(request):
|
|||||||
request, "apps/gameboard/gameboard.html", {
|
request, "apps/gameboard/gameboard.html", {
|
||||||
"pass_token": pass_token,
|
"pass_token": pass_token,
|
||||||
"coin": coin,
|
"coin": coin,
|
||||||
|
"carte": carte,
|
||||||
|
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
||||||
"free_tokens": free_tokens,
|
"free_tokens": free_tokens,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
"applets": applet_context(request.user, "gameboard"),
|
"applets": applet_context(request.user, "gameboard"),
|
||||||
@@ -53,6 +57,8 @@ def toggle_game_applets(request):
|
|||||||
"applets": applet_context(request.user, "gameboard"),
|
"applets": applet_context(request.user, "gameboard"),
|
||||||
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
"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(),
|
"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(
|
"free_tokens": list(request.user.tokens.filter(
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
).order_by("expires_at")),
|
).order_by("expires_at")),
|
||||||
@@ -66,3 +72,17 @@ def toggle_game_applets(request):
|
|||||||
).distinct(),
|
).distinct(),
|
||||||
})
|
})
|
||||||
return redirect("gameboard")
|
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},
|
||||||
|
)
|
||||||
|
|||||||
18
src/apps/lyric/migrations/0010_carte_blanche_token_type.py
Normal file
18
src/apps/lyric/migrations/0010_carte_blanche_token_type.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/apps/lyric/migrations/0011_user_equipped_trinket_fk.py
Normal file
19
src/apps/lyric/migrations/0011_user_equipped_trinket_fk.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/apps/lyric/migrations/0012_carte_slots_claimed.py
Normal file
18
src/apps/lyric/migrations/0012_carte_slots_claimed.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -33,6 +33,10 @@ class User(AbstractBaseUser):
|
|||||||
searchable = models.BooleanField(default=False)
|
searchable = models.BooleanField(default=False)
|
||||||
palette = models.CharField(max_length=32, default="palette-default")
|
palette = models.CharField(max_length=32, default="palette-default")
|
||||||
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
|
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_staff = models.BooleanField(default=False)
|
||||||
is_superuser = models.BooleanField(default=False)
|
is_superuser = models.BooleanField(default=False)
|
||||||
@@ -72,11 +76,13 @@ class Token(models.Model):
|
|||||||
FREE = "Free"
|
FREE = "Free"
|
||||||
TITHE = "tithe"
|
TITHE = "tithe"
|
||||||
PASS = "pass"
|
PASS = "pass"
|
||||||
|
CARTE = "carte"
|
||||||
TOKEN_TYPE_CHOICES = [
|
TOKEN_TYPE_CHOICES = [
|
||||||
(COIN, "Coin-on-a-String"),
|
(COIN, "Coin-on-a-String"),
|
||||||
(FREE, "Free Token"),
|
(FREE, "Free Token"),
|
||||||
(TITHE, "Tithe Token"),
|
(TITHE, "Tithe Token"),
|
||||||
(PASS, "Backstage Pass"),
|
(PASS, "Backstage Pass"),
|
||||||
|
(CARTE, "Carte Blanche"),
|
||||||
]
|
]
|
||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
|
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"
|
on_delete=models.SET_NULL, related_name="coin_tokens"
|
||||||
)
|
)
|
||||||
next_ready_at = models.DateTimeField(null=True, blank=True)
|
next_ready_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
slots_claimed = models.PositiveSmallIntegerField(default=0, blank=True)
|
||||||
|
|
||||||
def tooltip_name(self):
|
def tooltip_name(self):
|
||||||
return self.get_token_type_display()
|
return self.get_token_type_display()
|
||||||
@@ -98,10 +105,12 @@ class Token(models.Model):
|
|||||||
return "Admit All Entry"
|
return "Admit All Entry"
|
||||||
if self.token_type == self.TITHE:
|
if self.token_type == self.TITHE:
|
||||||
return "+ Writ bonus"
|
return "+ Writ bonus"
|
||||||
|
if self.token_type == self.CARTE:
|
||||||
|
return "Admit up to +6"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def tooltip_expiry(self):
|
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:
|
if self.token_type == self.COIN and self.next_ready_at:
|
||||||
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
|
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
|
||||||
return "no expiry"
|
return "no expiry"
|
||||||
@@ -118,8 +127,12 @@ class Token(models.Model):
|
|||||||
def tooltip_shoptalk(self):
|
def tooltip_shoptalk(self):
|
||||||
if self.token_type == self.COIN:
|
if self.token_type == self.COIN:
|
||||||
return "\u2026and another after that, and another after that\u2026"
|
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:
|
if self.token_type == self.PASS:
|
||||||
return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?"
|
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
|
return None
|
||||||
|
|
||||||
def tooltip_text(self):
|
def tooltip_text(self):
|
||||||
@@ -143,11 +156,15 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
|||||||
if not created:
|
if not created:
|
||||||
return
|
return
|
||||||
Wallet.objects.create(user=instance, writs=144)
|
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(
|
Token.objects.create(
|
||||||
user=instance,
|
user=instance,
|
||||||
token_type=Token.FREE,
|
token_type=Token.FREE,
|
||||||
expires_at=timezone.now() + timedelta(days=7),
|
expires_at=timezone.now() + timedelta(days=7),
|
||||||
)
|
)
|
||||||
if instance.is_staff:
|
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'])
|
||||||
|
|||||||
@@ -163,15 +163,64 @@ class TokenTooltipTest(TestCase):
|
|||||||
free.expires_at = None
|
free.expires_at = None
|
||||||
self.assertEqual(free.tooltip_expiry(), "")
|
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)
|
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):
|
def test_tooltip_room_html_returns_empty_when_no_room(self):
|
||||||
token = Token.objects.get(user=self.user, token_type=Token.COIN)
|
token = Token.objects.get(user=self.user, token_type=Token.COIN)
|
||||||
self.assertEqual(token.tooltip_room_html(), "")
|
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):
|
class PaymentMethodTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(email="pay@test.io")
|
self.user = User.objects.create(email="pay@test.io")
|
||||||
|
|||||||
@@ -55,3 +55,21 @@ class PassTokenTooltipTest(SimpleTestCase):
|
|||||||
def test_tooltip_contains_no_expiry(self):
|
def test_tooltip_contains_no_expiry(self):
|
||||||
self.assertIn("no expiry", self.token.tooltip_text())
|
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())
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(session_key)
|
self.stdout.write(session_key)
|
||||||
|
|
||||||
def create_pre_authenticated_session(email):
|
def create_pre_authenticated_session(email):
|
||||||
user = User.objects.create_user(email=email)
|
user, _ = User.objects.get_or_create(email=email)
|
||||||
session = SessionStore()
|
session = SessionStore()
|
||||||
session[SESSION_KEY] = str(user.pk) # Convert UUID to string for JSON serialization
|
session[SESSION_KEY] = str(user.pk) # Convert UUID to string for JSON serialization
|
||||||
session[BACKEND_SESSION_KEY] = "apps.lyric.authentication.PasswordlessAuthenticationBackend"
|
session[BACKEND_SESSION_KEY] = "apps.lyric.authentication.PasswordlessAuthenticationBackend"
|
||||||
|
|||||||
@@ -190,11 +190,9 @@ class GatekeeperTest(FunctionalTest):
|
|||||||
room.refresh_from_db()
|
room.refresh_from_db()
|
||||||
room.gate_status = Room.OPEN
|
room.gate_status = Room.OPEN
|
||||||
room.save()
|
room.save()
|
||||||
# 4. Gatekeeper disappears via htmx
|
# 4. Gate shows launch button via htmx when all slots filled
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertEqual(
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn")
|
||||||
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-modal")), 0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_owner_can_delete_room_via_gear_menu(self):
|
def test_owner_can_delete_room_via_gear_menu(self):
|
||||||
@@ -573,6 +571,8 @@ class GameKitInsertTest(FunctionalTest):
|
|||||||
self.gamer.is_staff = True
|
self.gamer.is_staff = True
|
||||||
self.gamer.save()
|
self.gamer.save()
|
||||||
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
|
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.browser.get(self.gate_url)
|
||||||
self._select_token_from_kit(pass_token)
|
self._select_token_from_kit(pass_token)
|
||||||
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
|
self.browser.find_element(By.CSS_SELECTOR, "button.token-rails").click()
|
||||||
|
|||||||
325
src/functional_tests/test_trinket_carte_blanche.py
Normal file
325
src/functional_tests/test_trinket_carte_blanche.py
Normal file
@@ -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")
|
||||||
|
)
|
||||||
@@ -6,7 +6,7 @@ body.page-gameboard {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
overflow: hidden;
|
overflow: clip;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -109,6 +109,18 @@ body.page-gameboard {
|
|||||||
&.active { display: block; }
|
&.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) {
|
@media (max-height: 500px) {
|
||||||
body.page-gameboard {
|
body.page-gameboard {
|
||||||
.container {
|
.container {
|
||||||
|
|||||||
@@ -395,16 +395,16 @@
|
|||||||
}
|
}
|
||||||
/* Monochrome Light Palette */
|
/* Monochrome Light Palette */
|
||||||
.palette-monochrome-light {
|
.palette-monochrome-light {
|
||||||
--priUser: var(--sixAg); /* 240,240,240 — light gray bg */
|
--priUser: var(--sixAdm); /* 240,240,240 — light gray bg */
|
||||||
--secUser: var(--terAg); /* 100,100,100 — mid-dark text/border */
|
--secUser: var(--terNi); /* 100,100,100 — mid-dark text/border */
|
||||||
--terUser: var(--secAg); /* 60,60,60 — dark accent */
|
--terUser: var(--priPer); /* 60,60,60 — dark accent */
|
||||||
--quaUser: var(--priAg); /* 30,30,30 — near-black active */
|
--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 */
|
--sixUser: var(--quiAg); /* 175,175,175 — subtle */
|
||||||
--sepUser: var(--sixAg); /* 240,240,240 — secondary subtle */
|
--sepUser: var(--sixAg); /* 240,240,240 — secondary subtle */
|
||||||
--octUser: var(--terNi); /* 93,95,94 — links */
|
--octUser: var(--terNi); /* 93,95,94 — links */
|
||||||
--ninUser: var(--priCtn); /* 255,251,246 — warm bright highlight */
|
--ninUser: var(--terPer); /* 255,251,246 — warm bright highlight */
|
||||||
--decUser: var(--secPt); /* 189,190,189 — light mid */
|
--decUser: var(--terPt); /* 189,190,189 — light mid */
|
||||||
}
|
}
|
||||||
/* Sepia Palette */
|
/* Sepia Palette */
|
||||||
.palette-sepia {
|
.palette-sepia {
|
||||||
|
|||||||
@@ -3,32 +3,49 @@
|
|||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
>
|
>
|
||||||
<h2>Game Kit</h2>
|
<h2>Game Kit</h2>
|
||||||
<div id="id_game_kit">
|
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}">
|
||||||
{% if pass_token %}
|
{% if pass_token %}
|
||||||
<div id="id_kit_pass_token" class="token">
|
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
|
||||||
<i class="fa-solid fa-clipboard"></i>
|
<i class="fa-solid fa-clipboard"></i>
|
||||||
<div class="token-tooltip">
|
<div class="token-tooltip">
|
||||||
<h4>{{ pass_token.tooltip_name }}</h4>
|
<div class="token-tooltip-body">
|
||||||
<p>{{ pass_token.tooltip_description }}</p>
|
<h4>{{ pass_token.tooltip_name }}</h4>
|
||||||
{% if pass_token.tooltip_shoptalk %}
|
<p>{{ pass_token.tooltip_description }}</p>
|
||||||
<small><em>{{ pass_token.tooltip_shoptalk }}</em></small>
|
{% if pass_token.tooltip_shoptalk %}
|
||||||
{% endif %}
|
<small><em>{{ pass_token.tooltip_shoptalk }}</em></small>
|
||||||
<p class="expiry">{{ pass_token.tooltip_expiry }}</p>
|
{% endif %}
|
||||||
|
<p class="expiry">{{ pass_token.tooltip_expiry }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if carte %}
|
||||||
|
<div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}">
|
||||||
|
<i class="fa-solid fa-money-check"></i>
|
||||||
|
<div class="token-tooltip">
|
||||||
|
<div class="token-tooltip-body">
|
||||||
|
<h4>{{ carte.tooltip_name }}</h4>
|
||||||
|
<p>{{ carte.tooltip_description }}</p>
|
||||||
|
{% if carte.tooltip_shoptalk %}
|
||||||
|
<small><em>{{ carte.tooltip_shoptalk }}</em></small>
|
||||||
|
{% endif %}
|
||||||
|
<p class="expiry">{{ carte.tooltip_expiry }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if coin %}
|
{% if coin %}
|
||||||
<div id="id_kit_coin_on_a_string" class="token">
|
<div id="id_kit_coin_on_a_string" class="token" data-token-id="{{ coin.pk }}">
|
||||||
<i class="fa-solid fa-medal"></i>
|
<i class="fa-solid fa-medal"></i>
|
||||||
<div class="token-tooltip">
|
<div class="token-tooltip">
|
||||||
<h4>{{ coin.tooltip_name }}</h4>
|
<div class="token-tooltip-body">
|
||||||
<p>
|
<h4>{{ coin.tooltip_name }}</h4>
|
||||||
{{ coin.tooltip_description }}
|
<p>{{ coin.tooltip_description }}</p>
|
||||||
</p>
|
{% if coin.tooltip_shoptalk %}
|
||||||
{% if coin.tooltip_shoptalk %}
|
<small><em>{{ coin.tooltip_shoptalk }}</em></small>
|
||||||
<small><em>{{ coin.tooltip_shoptalk }}</em></small>
|
{% endif %}
|
||||||
{% endif %}
|
<p class="expiry">{{ coin.tooltip_expiry }}</p>
|
||||||
<p class="expiry">{{ coin.tooltip_expiry }}</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -37,9 +54,14 @@
|
|||||||
<div id="id_kit_free_token" class="token">
|
<div id="id_kit_free_token" class="token">
|
||||||
<i class="fa-solid fa-coins"></i>
|
<i class="fa-solid fa-coins"></i>
|
||||||
<div class="token-tooltip">
|
<div class="token-tooltip">
|
||||||
<h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
<div class="token-tooltip-body">
|
||||||
<p>{{ token.tooltip_description }}</p>
|
<h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
||||||
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
<p>{{ token.tooltip_description }}</p>
|
||||||
|
{% if token.tooltip_shoptalk %}
|
||||||
|
<small><em>{{ token.tooltip_shoptalk }}</em></small>
|
||||||
|
{% endif %}
|
||||||
|
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<button class="equip-trinket-btn" data-token-id="{{ token.pk }}">Equip Trinket?</button>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot %} claimed{% elif token_depleted %} depleted{% else %} locked{% endif %}">
|
<div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot or carte_active %} claimed{% elif token_depleted %} depleted{% else %} locked{% endif %}">
|
||||||
{% if can_drop %}
|
{% if can_drop %}
|
||||||
<form method="POST" action="{% url 'epic:drop_token' room.id %}" style="display:contents">
|
<form method="POST" action="{% url 'epic:drop_token' room.id %}" style="display:contents">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -66,10 +66,25 @@
|
|||||||
<button type="submit" class="btn btn-confirm">OK</button>
|
<button type="submit" class="btn btn-confirm">OK</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
{% elif carte_active and slot.status == 'EMPTY' and slot.slot_number <= carte_slots_claimed|add:1 %}
|
||||||
|
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
|
||||||
|
<button type="submit" class="drop-token-btn" aria-label="Fill slot {{ slot.slot_number }}"></button>
|
||||||
|
</form>
|
||||||
|
{% elif carte_active and slot.status == 'FILLED' and slot.slot_number == carte_nvm_slot_number %}
|
||||||
|
<form method="POST" action="{% url 'epic:release_slot' room.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
|
||||||
|
<button type="submit" class="slot-release-btn btn btn-cancel">NVM</button>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if room.gate_status == 'OPEN' %}
|
||||||
|
<button class="launch-game-btn">Launch</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user == room.owner %}
|
{% if request.user == room.owner %}
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{% include "apps/gameboard/_partials/_applets.html" %}
|
{% include "apps/gameboard/_partials/_applets.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
||||||
|
<div id="id_mini_tooltip_portal" class="token-tooltip token-tooltip--mini"></div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="room-table"></div>
|
<div class="room-table"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if room.gate_status == "GATHERING" %}
|
{% if room.gate_status == "GATHERING" or room.gate_status == "OPEN" %}
|
||||||
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
<div class="token-tooltip">
|
<div class="token-tooltip">
|
||||||
<h4>{{ pass_token.tooltip_name }}</h4>
|
<h4>{{ pass_token.tooltip_name }}</h4>
|
||||||
<p>{{ pass_token.tooltip_description }}</p>
|
<p>{{ pass_token.tooltip_description }}</p>
|
||||||
|
{% if pass_token.tooltip_shoptalk %}
|
||||||
|
<small><em>{{ pass_token.tooltip_shoptalk }}</em></small>
|
||||||
|
{% endif %}
|
||||||
<p class="expiry">{{ pass_token.tooltip_expiry }}</p>
|
<p class="expiry">{{ pass_token.tooltip_expiry }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,6 +36,9 @@
|
|||||||
<div class="token-tooltip">
|
<div class="token-tooltip">
|
||||||
<h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
<h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
||||||
<p>{{ token.tooltip_description }}</p>
|
<p>{{ token.tooltip_description }}</p>
|
||||||
|
{% if token.tooltip_shoptalk %}
|
||||||
|
<small><em>{{ token.tooltip_shoptalk }}</em></small>
|
||||||
|
{% endif %}
|
||||||
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
{% if tokens %}
|
{% if equipped_trinket or free_token or tithe_token %}
|
||||||
<div class="kit-bag-section">
|
<div class="kit-bag-section">
|
||||||
<span class="kit-bag-label">Trinkets</span>
|
<span class="kit-bag-label">Trinkets</span>
|
||||||
<div class="kit-bag-row">
|
<div class="kit-bag-row">
|
||||||
{% for token in tokens %}
|
{% if equipped_trinket %}
|
||||||
{% if token.token_type == "coin" or token.token_type == "pass" %}
|
{% with token=equipped_trinket %}
|
||||||
<div
|
<div
|
||||||
class="token"
|
class="token"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
data-token-id="{{ token.id }}"
|
data-token-id="{{ token.id }}"
|
||||||
data-token-type="{{ token.token_type }}"
|
data-token-type="{{ token.token_type }}"
|
||||||
>
|
>
|
||||||
{% if token.token_type == "coin" %}
|
{% if token.token_type == "coin" %}
|
||||||
<i class="fa-solid fa-medal"></i>
|
<i class="fa-solid fa-medal"></i>
|
||||||
{% else %}
|
{% elif token.token_type == "carte" %}
|
||||||
<i class="fa-solid fa-clipboard"></i>
|
<i class="fa-solid fa-money-check"></i>
|
||||||
{% endif %}
|
{% else %}
|
||||||
<div class="token-tooltip">
|
<i class="fa-solid fa-clipboard"></i>
|
||||||
<h4>{{ token.tooltip_name }}</h4>
|
|
||||||
<p>{{ token.tooltip_description }}</p>
|
|
||||||
{% if token.tooltip_shoptalk %}
|
|
||||||
<small><em>{{ token.tooltip_shoptalk }}</em></small>
|
|
||||||
{% endif %}
|
|
||||||
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
<div class="token-tooltip">
|
||||||
|
<h4>{{ token.tooltip_name }}</h4>
|
||||||
|
<p>{{ token.tooltip_description }}</p>
|
||||||
|
{% if token.tooltip_shoptalk %}
|
||||||
|
<small><em>{{ token.tooltip_shoptalk }}</em></small>
|
||||||
|
{% endif %}
|
||||||
|
<p class="expiry">{{ token.tooltip_expiry }}</p>
|
||||||
|
{% with room_html=token.tooltip_room_html %}
|
||||||
|
{% if room_html %}{{ room_html|safe }}{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kit-bag-section">
|
<div class="kit-bag-section">
|
||||||
@@ -42,6 +47,9 @@
|
|||||||
<div class="token-tooltip">
|
<div class="token-tooltip">
|
||||||
<h4>{{ free_token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
<h4>{{ free_token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
|
||||||
<p>{{ free_token.tooltip_description }}</p>
|
<p>{{ free_token.tooltip_description }}</p>
|
||||||
|
{% if free_token.tooltip_shoptalk %}
|
||||||
|
<small><em>{{ free_token.tooltip_shoptalk }}</em></small>
|
||||||
|
{% endif %}
|
||||||
<p class="expiry">{{ free_token.tooltip_expiry }}</p>
|
<p class="expiry">{{ free_token.tooltip_expiry }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user