From f76c6d0fe5d6444f990098ebbfc8f7b5fccc59e1 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 14 Mar 2026 02:03:44 -0400 Subject: [PATCH] various styling & structural changes to unify site themes; token-drop interaction changes across epic urls & views --- src/apps/epic/tests/integrated/test_views.py | 157 +++++++++++++- src/apps/epic/urls.py | 5 +- src/apps/epic/views.py | 136 +++++++++--- src/functional_tests/test_gatekeeper.py | 201 +++++++++++++++--- src/static_src/scss/_base.scss | 6 +- src/static_src/scss/_room.scss | 125 ++++++++++- .../gameboard/_partials/_applet-new-game.html | 4 +- .../apps/gameboard/_partials/_gatekeeper.html | 74 +++++-- 8 files changed, 618 insertions(+), 90 deletions(-) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 5d8deea..827a0eb 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1,8 +1,9 @@ from django.test import TestCase from django.urls import reverse +from django.utils import timezone -from apps.lyric.models import User -from apps.epic.models import Room, RoomInvite +from apps.lyric.models import Token, User +from apps.epic.models import GateSlot, Room, RoomInvite class RoomCreationViewTest(TestCase): @@ -76,6 +77,158 @@ class GateStatusViewTest(TestCase): self.assertContains(response, "gate-modal") +class DropTokenViewTest(TestCase): + def setUp(self): + self.gamer = User.objects.create(email="gamer@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + + def test_drop_token_reserves_lowest_empty_slot(self): + self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id})) + slot = self.room.gate_slots.get(slot_number=1) + self.assertEqual(slot.status, GateSlot.RESERVED) + self.assertEqual(slot.gamer, self.gamer) + + def test_drop_token_skips_already_filled_slots(self): + other = User.objects.create(email="other@test.io") + slot1 = self.room.gate_slots.get(slot_number=1) + slot1.gamer = other + slot1.status = GateSlot.FILLED + slot1.save() + self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id})) + slot2 = self.room.gate_slots.get(slot_number=2) + self.assertEqual(slot2.status, GateSlot.RESERVED) + self.assertEqual(slot2.gamer, self.gamer) + + def test_drop_token_blocked_when_another_slot_reserved(self): + other = User.objects.create(email="other@test.io") + slot1 = self.room.gate_slots.get(slot_number=1) + slot1.gamer = other + slot1.status = GateSlot.RESERVED + slot1.reserved_at = timezone.now() + slot1.save() + self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id})) + # Slot 2 should remain EMPTY — lock held by other user + slot2 = self.room.gate_slots.get(slot_number=2) + self.assertEqual(slot2.status, GateSlot.EMPTY) + + def test_drop_token_blocked_when_user_already_has_filled_slot(self): + slot1 = self.room.gate_slots.get(slot_number=1) + slot1.gamer = self.gamer + slot1.status = GateSlot.FILLED + slot1.save() + self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id})) + slot2 = self.room.gate_slots.get(slot_number=2) + self.assertEqual(slot2.status, GateSlot.EMPTY) + + def test_drop_token_sets_reserved_at(self): + self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id})) + slot = self.room.gate_slots.get(slot_number=1) + self.assertIsNotNone(slot.reserved_at) + + def test_drop_token_redirects_to_gatekeeper(self): + response = self.client.post( + reverse("epic:drop_token", kwargs={"room_id": self.room.id}) + ) + self.assertRedirects( + response, reverse("epic:gatekeeper", args=[self.room.id]) + ) + + +class ConfirmTokenViewTest(TestCase): + def setUp(self): + self.gamer = User.objects.create(email="gamer@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + self.slot = self.room.gate_slots.get(slot_number=1) + self.slot.gamer = self.gamer + self.slot.status = GateSlot.RESERVED + self.slot.reserved_at = timezone.now() + self.slot.save() + Token.objects.create(user=self.gamer, token_type=Token.FREE) + + def test_confirm_marks_slot_filled(self): + self.client.post( + reverse("epic:confirm_token", kwargs={"room_id": self.room.id}) + ) + self.slot.refresh_from_db() + self.assertEqual(self.slot.status, GateSlot.FILLED) + + def test_confirm_sets_gate_open_when_all_slots_filled(self): + # Fill slots 2–6 via ORM + for i in range(2, 7): + other = User.objects.create(email=f"g{i}@test.io") + s = self.room.gate_slots.get(slot_number=i) + s.gamer = other + s.status = GateSlot.FILLED + s.save() + self.client.post( + reverse("epic:confirm_token", kwargs={"room_id": self.room.id}) + ) + self.room.refresh_from_db() + self.assertEqual(self.room.gate_status, Room.OPEN) + + def test_confirm_redirects_to_gatekeeper(self): + response = self.client.post( + reverse("epic:confirm_token", kwargs={"room_id": self.room.id}) + ) + self.assertRedirects( + response, reverse("epic:gatekeeper", args=[self.room.id]) + ) + + def test_confirm_does_nothing_without_reserved_slot(self): + self.slot.status = GateSlot.EMPTY + self.slot.gamer = None + self.slot.save() + self.client.post( + reverse("epic:confirm_token", kwargs={"room_id": self.room.id}) + ) + self.slot.refresh_from_db() + self.assertEqual(self.slot.status, GateSlot.EMPTY) + + +class RejectTokenViewTest(TestCase): + def setUp(self): + self.gamer = User.objects.create(email="gamer@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + self.slot = self.room.gate_slots.get(slot_number=1) + self.slot.gamer = self.gamer + self.slot.status = GateSlot.RESERVED + self.slot.reserved_at = timezone.now() + self.slot.save() + + def test_reject_clears_reserved_slot(self): + self.client.post( + reverse("epic:reject_token", kwargs={"room_id": self.room.id}) + ) + self.slot.refresh_from_db() + self.assertEqual(self.slot.status, GateSlot.EMPTY) + self.assertIsNone(self.slot.gamer) + self.assertIsNone(self.slot.reserved_at) + + def test_reject_after_confirm_clears_filled_slot(self): + self.slot.status = GateSlot.FILLED + self.slot.save() + self.client.post( + reverse("epic:reject_token", kwargs={"room_id": self.room.id}) + ) + self.slot.refresh_from_db() + self.assertEqual(self.slot.status, GateSlot.EMPTY) + self.assertIsNone(self.slot.gamer) + + def test_reject_redirects_to_gatekeeper(self): + response = self.client.post( + reverse("epic:reject_token", kwargs={"room_id": self.room.id}) + ) + self.assertRedirects( + response, reverse("epic:gatekeeper", args=[self.room.id]) + ) + + class RoomActionsViewTest(TestCase): def setUp(self): self.owner = User.objects.create(email="owner@test.io") diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 5694b5b..868a71e 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -7,10 +7,11 @@ app_name = 'epic' urlpatterns = [ path('rooms/create_room', views.create_room, name='create_room'), path('room//gate/', views.gatekeeper, name='gatekeeper'), - path('room//gate//drop_token', views.drop_token, name='drop_token'), + 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/reject_token', views.reject_token, name='reject_token'), 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'), path('room//abandon', views.abandon_room, name='abandon_room'), ] - diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index d10f01f..7d7c226 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,11 +1,56 @@ +from datetime import timedelta + from django.contrib.auth.decorators import login_required from django.http import HttpResponse from django.shortcuts import redirect, render +from django.utils import timezone -from apps.epic.models import Room, RoomInvite, debit_token +from apps.epic.models import GateSlot, Room, RoomInvite, debit_token from apps.lyric.models import Token +RESERVE_TIMEOUT = timedelta(seconds=60) + + +def _expire_reserved_slots(room): + cutoff = timezone.now() - RESERVE_TIMEOUT + room.gate_slots.filter( + status=GateSlot.RESERVED, + reserved_at__lt=cutoff, + ).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None) + + +def _gate_context(room, user): + _expire_reserved_slots(room) + slots = room.gate_slots.order_by("slot_number") + pending_slot = slots.filter(status=GateSlot.RESERVED).first() + user_reserved_slot = None + user_filled_slot = 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() + can_drop = ( + user.is_authenticated + and pending_slot is None + and user_reserved_slot is None + and user_filled_slot is None + ) + is_last_slot = ( + 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 + return { + "slots": slots, + "pending_slot": pending_slot, + "user_reserved_slot": user_reserved_slot, + "user_filled_slot": user_filled_slot, + "can_drop": can_drop, + "is_last_slot": is_last_slot, + "user_can_reject": user_can_reject, + } + + @login_required def create_room(request): if request.method == "POST": @@ -15,33 +60,68 @@ def create_room(request): return redirect("epic:gatekeeper", room_id=room.id) return redirect("/gameboard/") + def gatekeeper(request, room_id): room = Room.objects.get(id=room_id) - slots = room.gate_slots.order_by("slot_number") - user_has_slot = ( - request.user.is_authenticated - and room.gate_slots.filter(gamer=request.user).exists() - ) - return render(request, "apps/gameboard/room.html", { - "room": room, - "slots": slots, - "user_has_slot": user_has_slot, - }) + ctx = _gate_context(room, request.user) + ctx["room"] = room + return render(request, "apps/gameboard/room.html", ctx) + @login_required -def drop_token(request, room_id, slot_number): +def drop_token(request, room_id): if request.method == "POST": room = Room.objects.get(id=room_id) - slot = room.gate_slots.get(slot_number=slot_number) - token = ( - request.user.tokens.filter(token_type=Token.COIN).first() - or request.user.tokens.filter(token_type=Token.FREE).first() - or request.user.tokens.filter(token_type=Token.TITHE).first() - ) - if token: - debit_token(request.user, slot, token) + 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() + if slot: + slot.gamer = request.user + slot.status = GateSlot.RESERVED + slot.reserved_at = timezone.now() + slot.save() return redirect("epic:gatekeeper", room_id=room_id) + +@login_required +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 = ( + request.user.tokens.filter(token_type=Token.COIN).first() + or request.user.tokens.filter(token_type=Token.FREE).first() + or request.user.tokens.filter(token_type=Token.TITHE).first() + ) + if token: + debit_token(request.user, slot, token) + return redirect("epic:gatekeeper", room_id=room_id) + + +@login_required +def reject_token(request, room_id): + if request.method == "POST": + room = Room.objects.get(id=room_id) + slot = room.gate_slots.filter( + gamer=request.user, + status__in=[GateSlot.RESERVED, GateSlot.FILLED], + ).first() + if slot: + slot.gamer = None + slot.status = GateSlot.EMPTY + slot.reserved_at = None + slot.filled_at = None + slot.save() + return redirect("epic:gatekeeper", room_id=room_id) + + @login_required def invite_gamer(request, room_id): if request.method == "POST": @@ -56,6 +136,7 @@ def invite_gamer(request, room_id): ) return redirect("epic:gatekeeper", room_id=room_id) + @login_required def delete_room(request, room_id): if request.method == "POST": @@ -64,6 +145,7 @@ def delete_room(request, room_id): room.delete() return redirect("/gameboard/") + @login_required def abandon_room(request, room_id): if request.method == "POST": @@ -77,17 +159,11 @@ def abandon_room(request, room_id): ).delete() return redirect("/gameboard/") + def gate_status(request, room_id): room = Room.objects.get(id=room_id) if room.gate_status == Room.OPEN: return HttpResponse("") - slots = room.gate_slots.order_by("slot_number") - user_has_slot = ( - request.user.is_authenticated - and slots.filter(gamer=request.user).exists() - ) - return render(request, "apps/gameboard/_partials/_gatekeeper.html", { - "room": room, - "slots": slots, - "user_has_slot": user_has_slot, - }) + ctx = _gate_context(room, request.user) + ctx["room"] = room + return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx) diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 20949b2..fd550a9 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -1,5 +1,7 @@ import time +from django.utils import timezone + from selenium.webdriver.common.by import By from .base import FunctionalTest @@ -20,7 +22,7 @@ class GatekeeperTest(FunctionalTest): def test_founder_creates_room_and_sees_gatekeeper(self): # 1. Log in, navigate to gameboard - self.create_pre_authenticated_session("founder@test.io") + self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.live_server_url + "/gameboard/") # 2. New Game applet has room name input, create button self.wait_for( @@ -39,14 +41,16 @@ class GatekeeperTest(FunctionalTest): body = self.browser.find_element(By.TAG_NAME, "body") self.assertIn("Test Room", body.text) self.assertIn("GATHERING GAMERS", body.text) - # 5. Six token slots are visible + # 5. Six token slot circles are visible, all empty slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") self.assertEqual(len(slots), 6) - # 6. Slot 1 has Drop Token btn; slots 2–6 show as empty - slot_1 = slots[0] - slot_1.find_element(By.CSS_SELECTOR, ".drop-token-btn") - for slot in slots[1:]: + for slot in slots: self.assertIn("empty", slot.get_attribute("class")) + # 6. Shared coin slot is present; no individual drop buttons + self.browser.find_element(By.CSS_SELECTOR, ".token-slot") + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0 + ) def test_founder_drops_token_and_slot_fills(self): # 1. Set up: log in, create room, arrive at gatekeeper @@ -60,20 +64,25 @@ class GatekeeperTest(FunctionalTest): self.wait_for( lambda: self.assertIn("/gate/", self.browser.current_url) ) - # 2. Founder clicks Drop Token on slot 1 - drop_btn = self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn") + # 2. Founder clicks Insert Token via the shared coin slot + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + # 3. Slot 1 (lowest) now shows OK button; slot is reserved + ok_btn = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".gate-slot[data-slot='1'] .btn-confirm" + ) ) - drop_btn.click() - - # 3. Slot 1 now filled; drop btn gone + # 4. Founder clicks OK → slot fills + ok_btn.click() self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") ) slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") self.assertIn("filled", slots[0].get_attribute("class")) self.assertEqual( - len(self.browser.find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0 + len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 ) def test_room_appears_in_my_games_after_creation(self): @@ -96,9 +105,9 @@ class GatekeeperTest(FunctionalTest): self.assertIn("Dragon's Den", my_games.text) def test_second_gamer_drops_token_into_open_slot(self): - # 1. Founder creates room, fills slot 1 + # 1. Founder creates room, confirms slot 1 self.create_pre_authenticated_session("founder@test.io") - self.browser.get(self.live_server_url +"/gameboard/") + self.browser.get(self.live_server_url + "/gameboard/") self.wait_for( lambda: self.browser.find_element(By.ID, "id_new_game_name") ) @@ -108,11 +117,16 @@ class GatekeeperTest(FunctionalTest): lambda: self.assertIn("/gate/", self.browser.current_url) ) room_url = self.browser.current_url - self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn").click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") ) - # 2. Founder invites friend via email (duplicate invite logic from My Notes applet) + # 2. Founder invites friend invite_input = self.wait_for( lambda: self.browser.find_element(By.ID, "id_invite_email") ) @@ -125,14 +139,16 @@ class GatekeeperTest(FunctionalTest): lambda: self.browser.find_element(By.ID, "id_applet_my_games") ) self.assertIn("Dragon's Den", my_games.text) - # 3. Friend follows link to gatekeeper + # 4. Friend follows link to gatekeeper self.browser.find_element(By.LINK_TEXT, "Dragon's Den").click() - # 4. Friend sees drop btn on open slot - drop_btn = self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn") - ) - drop_btn.click() - # 5. Now two slots filled + # 5. Friend drops token via coin slot and confirms + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() + # 6. Now two slots filled self.wait_for( lambda: self.assertEqual( len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot.filled")), 2 @@ -151,12 +167,17 @@ class GatekeeperTest(FunctionalTest): self.wait_for( lambda: self.assertIn("/gate/", self.browser.current_url) ) - room_url = self.browser.current_url - # 2. Fill all 6 slots directly via ORM (founder + 5 extras) - self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn").click() + # 2. Founder confirms slot 1 via coin slot + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") ) + # 3. Fill slots 2–6 directly via ORM room = Room.objects.get(name="Dragon's Den") for i, email in enumerate([ "g2@test.io", "g3@test.io", "g4@test.io", "g5@test.io", "g6@test.io" @@ -169,15 +190,12 @@ class GatekeeperTest(FunctionalTest): room.refresh_from_db() room.gate_status = Room.OPEN room.save() - # 3. Gatekeeper disappears via htmx + # 4. Gatekeeper disappears via htmx self.wait_for( lambda: self.assertEqual( len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-modal")), 0 ) ) - # Restore the following once room built - # body = self.browser.find_element(By.TAG_NAME, "body") - # self.assertIn("OPEN", body.text) def test_owner_can_delete_room_via_gear_menu(self): self.create_pre_authenticated_session("founder@test.io") @@ -237,3 +255,124 @@ class GatekeeperTest(FunctionalTest): slot.refresh_from_db() self.assertEqual(slot.status, "EMPTY") self.assertIsNone(slot.gamer) + + +class CoinSlotTest(FunctionalTest): + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="new-game", defaults={"name": "New Game", "context": "gameboard"} + ) + Applet.objects.get_or_create( + slug="my-games", defaults={"name": "My Games", "context": "gameboard"} + ) + self.create_pre_authenticated_session("founder@test.io") + self.founder = User.objects.get(email="founder@test.io") + self.room = Room.objects.create(name="Coin Room", owner=self.founder) + self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/" + + def test_coin_slot_active_for_eligible_gamer(self): + # Gamer with no slot arrives at gatekeeper — coin slot is active + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.active") + ) + self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + + def test_drop_token_reserves_lowest_empty_slot(self): + # Gamer drops token; slot 1 (lowest) becomes reserved with OK button + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".gate-slot[data-slot='1'] .btn-confirm" + ) + ) + slot = self.room.gate_slots.get(slot_number=1) + slot.refresh_from_db() + self.assertEqual(slot.status, GateSlot.RESERVED) + self.assertEqual(slot.gamer, self.founder) + + def test_confirm_fills_slot_and_removes_ok_button(self): + # Drop then confirm → slot 1 FILLED, OK button gone + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") + ) + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 + ) + slot = self.room.gate_slots.get(slot_number=1) + slot.refresh_from_db() + self.assertEqual(slot.status, GateSlot.FILLED) + + def test_gamer_can_reject_pending_token(self): + # Drop then reject via Push to Reject → slot remains empty + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + # Push to Reject appears in coin slot + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-reject-btn") + ).click() + # Slot 1 still empty; coin slot active again + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.active") + ) + slot = self.room.gate_slots.get(slot_number=1) + slot.refresh_from_db() + self.assertEqual(slot.status, GateSlot.EMPTY) + + def test_coin_slot_locked_while_another_token_is_pending(self): + # Pre-set slot 1 as RESERVED by a different user + other = User.objects.create(email="other@test.io") + slot = self.room.gate_slots.get(slot_number=1) + slot.gamer = other + slot.status = GateSlot.RESERVED + slot.reserved_at = timezone.now() + slot.save() + # Current user (founder) sees coin slot locked + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.locked") + ) + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".token-insert-btn")), 0 + ) + + def test_last_gamer_sees_pick_roles_button(self): + # Fill slots 1–5 via ORM; slot 6 empty + for i, email in enumerate([ + "g1@test.io", "g2@test.io", "g3@test.io", "g4@test.io", "g5@test.io" + ], start=1): + gamer = User.objects.create(email=email) + slot = self.room.gate_slots.get(slot_number=i) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + # Founder (no slot yet) drops token → gets slot 6 + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-insert-btn") + ).click() + # Slot 6 shows PICK ROLES instead of OK + self.wait_for( + lambda: self.assertIn( + "PICK ROLES", + self.browser.find_element( + By.CSS_SELECTOR, ".gate-slot[data-slot='6']" + ).text, + ) + ) + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 + ) diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index 696048c..f5401f7 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -232,9 +232,13 @@ body { .row .col-lg-6 h2 { text-align: center; text-align-last: center; - letter-spacing: 0.25em; + letter-spacing: 0.33em; margin: 0 0 0.5rem; font-size: 2rem; + + &#id_dash_wallet { + letter-spacing: 0.25em; + } } } } diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 66c6222..8a5a45c 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -37,7 +37,7 @@ $gate-line: 2px; .gate-header { text-align: center; - h1 { margin: 0; } + h1 { margin: 0 0 0.5rem; } .gate-status-wrap { display: flex; justify-content: center; @@ -58,6 +58,114 @@ $gate-line: 2px; } } + .token-slot { + position: relative; + display: flex; + flex-direction: row; + border: 2px solid rgba(var(--terUser), 0.7); + border-radius: 0.4rem; + background: rgba(0, 0, 0, 0.35); + min-width: 180px; + + &.locked { + opacity: 0.3; + pointer-events: none; + } + + &.pending, + &.claimed { + box-shadow: + 0 0 0.6rem rgba(var(--terUser), 0.5), + 0 0 1.4rem rgba(var(--terUser), 0.2), + ; + .token-reject-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); } + + &:hover { + border-color: rgba(var(--terUser), 1); + background: rgba(0, 0, 0, 0.55); + box-shadow: + 0 0 0.8rem rgba(var(--terUser), 0.75), + 0 0 2rem rgba(var(--terUser), 0.35), + ; + } + } + + .token-rails, + button.token-rails { + display: flex; + flex-direction: row; + align-items: stretch; + padding: 0.6rem 0.45rem; + gap: 0.2rem; + border-right: 1px solid rgba(var(--terUser), 0.35); + + .rail { + display: block; + width: 2px; + background: rgba(var(--terUser), 0.55); + border-radius: 1px; + } + } + + button.token-rails { + background: transparent; + border: none; + border-right: 1px solid rgba(var(--terUser), 0.35); + cursor: pointer; + border-radius: 0.3rem 0 0 0.3rem; + + &:hover { + background: rgba(var(--terUser), 0.1); + .rail { background: rgba(var(--terUser), 1); } + } + } + + .token-reject-overlay { + position: absolute; + inset: 0; + background: transparent; + border: none; + cursor: pointer; + border-radius: inherit; + } + + .token-panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0.45rem 0.75rem; + gap: 0.15rem; + + .token-denomination { + font-size: 1.5em; + font-weight: bold; + color: rgba(var(--terUser), 1); + line-height: 1; + } + + .token-insert-label, + .token-insert-btn { + font-size: 0.6em; + text-transform: uppercase; + letter-spacing: 0.08em; + text-align: center; + line-height: 1.3; + } + + .token-reject-label, + .token-reject-btn { + font-size: 0.55em; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.5; + line-height: 1.3; + text-align: center; + } + + } + } + .gate-slots { display: flex; flex-direction: row; @@ -76,10 +184,20 @@ $gate-line: 2px; justify-content: center; flex-shrink: 0; - &.filled { + &.filled, + &.reserved { background: rgba(var(--terUser), 0.2); } + &.filled:hover, + &.reserved:hover { + box-shadow: + -0.1rem -0.1rem 1rem rgba(var(--ninUser), 1), + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1), + 0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1), + ; + } + .slot-number { font-size: 0.7em; opacity: 0.5; @@ -90,6 +208,9 @@ $gate-line: 2px; form { position: absolute; inset: 0; + display: flex; + align-items: center; + justify-content: center; } .drop-token-btn { diff --git a/src/templates/apps/gameboard/_partials/_applet-new-game.html b/src/templates/apps/gameboard/_partials/_applet-new-game.html index 698a6a8..82535a3 100644 --- a/src/templates/apps/gameboard/_partials/_applet-new-game.html +++ b/src/templates/apps/gameboard/_partials/_applet-new-game.html @@ -3,9 +3,9 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

New Game

-
+ {% csrf_token %} - +
\ No newline at end of file diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 0aae1ef..d660aa6 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -6,6 +6,7 @@ >
+