From 01de6e7548e07640b3ab3a2cff8c0ea07ad6b726 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 17 Mar 2026 00:24:23 -0400 Subject: [PATCH] Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped --- .../apps/{scripts => applets}/applets.js | 0 .../apps/{scripts => dashboard}/dashboard.js | 0 .../apps/{scripts => dashboard}/game-kit.js | 0 .../apps/{scripts => dashboard}/wallet.js | 0 src/apps/epic/consumers.py | 13 +- .../0006_table_status_and_table_seat.py | 33 + src/apps/epic/models.py | 40 ++ src/apps/epic/routing.py | 2 +- src/apps/epic/static/apps/epic/gatekeeper.js | 12 + src/apps/epic/static/apps/epic/role-select.js | 163 +++++ src/apps/epic/static/apps/epic/room.js | 17 + .../epic/tests/integrated/test_consumers.py | 56 +- src/apps/epic/tests/integrated/test_models.py | 58 +- src/apps/epic/tests/integrated/test_views.py | 296 ++++++++- src/apps/epic/urls.py | 2 + src/apps/epic/views.py | 161 ++++- .../0013_alter_token_slots_claimed.py | 18 + src/core/settings.py | 6 + src/functional_tests/base.py | 79 +++ src/functional_tests/test_gatekeeper.py | 4 +- src/functional_tests/test_room_role_select.py | 567 ++++++++++++++++++ src/static_src/scss/_applets.scss | 3 +- src/static_src/scss/_base.scss | 5 +- src/static_src/scss/_room.scss | 332 +++++++++- src/static_src/scss/rootvars.scss | 16 +- src/static_src/tests/RoleSelectSpec.js | 240 ++++++++ src/static_src/tests/SpecRunner.html | 4 +- .../apps/dashboard/_partials/_scripts.html | 3 +- src/templates/apps/dashboard/wallet.html | 2 +- .../apps/gameboard/_partials/_gatekeeper.html | 9 +- src/templates/apps/gameboard/room.html | 66 +- src/templates/core/base.html | 4 +- 32 files changed, 2148 insertions(+), 63 deletions(-) rename src/apps/applets/static/apps/{scripts => applets}/applets.js (100%) rename src/apps/dashboard/static/apps/{scripts => dashboard}/dashboard.js (100%) rename src/apps/dashboard/static/apps/{scripts => dashboard}/game-kit.js (100%) rename src/apps/dashboard/static/apps/{scripts => dashboard}/wallet.js (100%) create mode 100644 src/apps/epic/migrations/0006_table_status_and_table_seat.py create mode 100644 src/apps/epic/static/apps/epic/gatekeeper.js create mode 100644 src/apps/epic/static/apps/epic/role-select.js create mode 100644 src/apps/epic/static/apps/epic/room.js create mode 100644 src/apps/lyric/migrations/0013_alter_token_slots_claimed.py create mode 100644 src/functional_tests/test_room_role_select.py create mode 100644 src/static_src/tests/RoleSelectSpec.js diff --git a/src/apps/applets/static/apps/scripts/applets.js b/src/apps/applets/static/apps/applets/applets.js similarity index 100% rename from src/apps/applets/static/apps/scripts/applets.js rename to src/apps/applets/static/apps/applets/applets.js diff --git a/src/apps/dashboard/static/apps/scripts/dashboard.js b/src/apps/dashboard/static/apps/dashboard/dashboard.js similarity index 100% rename from src/apps/dashboard/static/apps/scripts/dashboard.js rename to src/apps/dashboard/static/apps/dashboard/dashboard.js diff --git a/src/apps/dashboard/static/apps/scripts/game-kit.js b/src/apps/dashboard/static/apps/dashboard/game-kit.js similarity index 100% rename from src/apps/dashboard/static/apps/scripts/game-kit.js rename to src/apps/dashboard/static/apps/dashboard/game-kit.js diff --git a/src/apps/dashboard/static/apps/scripts/wallet.js b/src/apps/dashboard/static/apps/dashboard/wallet.js similarity index 100% rename from src/apps/dashboard/static/apps/scripts/wallet.js rename to src/apps/dashboard/static/apps/dashboard/wallet.js diff --git a/src/apps/epic/consumers.py b/src/apps/epic/consumers.py index bc19fba..5563900 100644 --- a/src/apps/epic/consumers.py +++ b/src/apps/epic/consumers.py @@ -3,8 +3,8 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer class RoomConsumer(AsyncJsonWebsocketConsumer): async def connect(self): - self.room_slug = self.scope["url_route"]["kwargs"]["room_slug"] - self.group_name = f"room_{self.room_slug}" + self.room_id = self.scope["url_route"]["kwargs"]["room_id"] + self.group_name = f"room_{self.room_id}" await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() @@ -16,3 +16,12 @@ class RoomConsumer(AsyncJsonWebsocketConsumer): async def gate_update(self, event): await self.send_json(event) + + async def role_select_start(self, event): + await self.send_json(event) + + async def turn_changed(self, event): + await self.send_json(event) + + async def roles_revealed(self, event): + await self.send_json(event) diff --git a/src/apps/epic/migrations/0006_table_status_and_table_seat.py b/src/apps/epic/migrations/0006_table_status_and_table_seat.py new file mode 100644 index 0000000..70ccbd3 --- /dev/null +++ b/src/apps/epic/migrations/0006_table_status_and_table_seat.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0 on 2026-03-17 00:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0005_gateslot_debited_token_fields'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='room', + name='table_status', + field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('IN_GAME', 'In Game')], max_length=20, null=True), + ), + migrations.CreateModel( + name='TableSeat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slot_number', models.IntegerField()), + ('role', models.CharField(blank=True, choices=[('PC', 'Player'), ('BC', 'Builder'), ('SC', 'Shepherd'), ('AC', 'Alchemist'), ('NC', 'Narrator'), ('EC', 'Economist')], max_length=2, null=True)), + ('role_revealed', models.BooleanField(default=False)), + ('seat_position', models.IntegerField(blank=True, null=True)), + ('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='table_seats', to=settings.AUTH_USER_MODEL)), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_seats', to='epic.room')), + ], + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 8125f5f..139819f 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -29,6 +29,15 @@ class Room(models.Model): (INVITE_ONLY, "Invite Only"), ] + ROLE_SELECT = "ROLE_SELECT" + SIG_SELECT = "SIG_SELECT" + IN_GAME = "IN_GAME" + TABLE_STATUS_CHOICES = [ + (ROLE_SELECT, "Role Select"), + (SIG_SELECT, "Significator Select"), + (IN_GAME, "In Game"), + ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=200) owner = models.ForeignKey( @@ -36,6 +45,9 @@ class Room(models.Model): ) visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE) gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING) + table_status = models.CharField( + max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True + ) renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7)) created_at = models.DateTimeField(auto_now_add=True) board_state = models.JSONField(default=dict) @@ -133,3 +145,31 @@ def debit_token(user, slot, token): if not room.gate_slots.filter(status=GateSlot.EMPTY).exists(): room.gate_status = Room.OPEN room.save() + + +class TableSeat(models.Model): + PC = "PC" + BC = "BC" + SC = "SC" + AC = "AC" + NC = "NC" + EC = "EC" + ROLE_CHOICES = [ + (PC, "Player"), + (BC, "Builder"), + (SC, "Shepherd"), + (AC, "Alchemist"), + (NC, "Narrator"), + (EC, "Economist"), + ] + PARTNER_MAP = {PC: BC, BC: PC, SC: AC, AC: SC, NC: EC, EC: NC} + + room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="table_seats") + gamer = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name="table_seats" + ) + slot_number = models.IntegerField() + role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True) + role_revealed = models.BooleanField(default=False) + seat_position = models.IntegerField(null=True, blank=True) diff --git a/src/apps/epic/routing.py b/src/apps/epic/routing.py index e2d472c..cb4567b 100644 --- a/src/apps/epic/routing.py +++ b/src/apps/epic/routing.py @@ -4,5 +4,5 @@ from . import consumers websocket_urlpatterns = [ - path('ws/room//', consumers.RoomConsumer.as_asgi()), + path('ws/room//', consumers.RoomConsumer.as_asgi()), ] diff --git a/src/apps/epic/static/apps/epic/gatekeeper.js b/src/apps/epic/static/apps/epic/gatekeeper.js new file mode 100644 index 0000000..5f4cc35 --- /dev/null +++ b/src/apps/epic/static/apps/epic/gatekeeper.js @@ -0,0 +1,12 @@ +(function () { + window.addEventListener('room:gate_update', function () { + const wrapper = document.getElementById('id_gate_wrapper'); + if (!wrapper) return; + + fetch(wrapper.dataset.gateStatusUrl) + .then(function (r) { return r.text(); }) + .then(function (html) { + wrapper.outerHTML = html; + }); + }); +}()); \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js new file mode 100644 index 0000000..fd79499 --- /dev/null +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -0,0 +1,163 @@ +var RoleSelect = (function () { + var ROLES = [ + { code: "PC", name: "Player", element: "Fire" }, + { code: "BC", name: "Builder", element: "Stone" }, + { code: "SC", name: "Shepherd", element: "Air" }, + { code: "AC", name: "Alchemist", element: "Water" }, + { code: "NC", name: "Narrator", element: "Time" }, + { code: "EC", name: "Economist", element: "Space" }, + ]; + + function getSelectRoleUrl() { + var el = document.querySelector("[data-select-role-url]"); + return el ? el.dataset.selectRoleUrl : null; + } + + function getCsrf() { + var m = document.cookie.match(/csrftoken=([^;]+)/); + return m ? m[1] : ""; + } + + function closeFan() { + var backdrop = document.querySelector(".role-select-backdrop"); + if (backdrop) backdrop.remove(); + } + + function selectRole(roleCode, cardEl) { + var invCard = cardEl.cloneNode(true); + invCard.classList.add("flipped"); + // strip old event listeners from the clone by replacing with a clean copy + var clean = invCard.cloneNode(true); + + closeFan(); + + var invSlot = document.getElementById("id_inv_role_card"); + if (invSlot) invSlot.appendChild(clean); + + // Update the stack's taken-roles so the next openFan() filters correctly + var stack = document.querySelector(".card-stack[data-taken-roles]"); + if (stack) { + var current = stack.dataset.takenRoles; + stack.dataset.takenRoles = current ? current + "," + roleCode : roleCode; + } + + var url = getSelectRoleUrl(); + if (!url) return; + fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRFToken": getCsrf(), + }, + body: "role=" + encodeURIComponent(roleCode), + }); + } + + function getTakenRoles() { + var stack = document.querySelector(".card-stack[data-taken-roles]"); + if (!stack) return []; + var raw = stack.dataset.takenRoles; + return raw ? raw.split(",").map(function (s) { return s.trim(); }) : []; + } + + function openFan() { + if (document.querySelector(".role-select-backdrop")) return; + + var taken = getTakenRoles(); + var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; }); + + var backdrop = document.createElement("div"); + backdrop.className = "role-select-backdrop"; + + var modal = document.createElement("div"); + modal.id = "id_role_select"; + + available.forEach(function (role) { + var card = document.createElement("div"); + card.className = "card"; + card.dataset.role = role.code; + + var back = document.createElement("div"); + back.className = "card-back"; + back.textContent = "?"; + + var front = document.createElement("div"); + front.className = "card-front"; + front.innerHTML = '
' + role.name + "
"; + + card.appendChild(back); + card.appendChild(front); + + card.addEventListener("mouseenter", function () { + card.classList.add("flipped"); + }); + card.addEventListener("mouseleave", function () { + card.classList.remove("flipped"); + }); + card.addEventListener("click", function (e) { + e.stopPropagation(); + selectRole(role.code, card); + }); + + modal.appendChild(card); + }); + + backdrop.appendChild(modal); + backdrop.addEventListener("click", closeFan); + document.body.appendChild(backdrop); + } + + function init() { + var stack = document.querySelector(".card-stack[data-state='eligible']"); + if (!stack) return; + stack.addEventListener("click", openFan); + } + + var _reload = function () { window.location.reload(); }; + + function handleRolesRevealed() { + _reload(); + } + + function handleTurnChanged(event) { + var active = String(event.detail.active_slot); + + // Update card-stack eligibility + var stack = document.querySelector(".card-stack[data-user-slots]"); + if (stack) { + var userSlots = stack.dataset.userSlots + ? stack.dataset.userSlots.split(",") : []; + if (userSlots.indexOf(active) !== -1) { + stack.dataset.state = "eligible"; + stack.removeEventListener("click", openFan); + stack.addEventListener("click", openFan); + } else { + stack.dataset.state = "ineligible"; + stack.removeEventListener("click", openFan); + } + } + + // Move .active to the newly active seat + document.querySelectorAll(".table-seat.active").forEach(function (s) { + s.classList.remove("active"); + }); + var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']"); + if (activeSeat) activeSeat.classList.add("active"); + } + + window.addEventListener("room:role_select_start", init); + window.addEventListener("room:turn_changed", handleTurnChanged); + window.addEventListener("room:roles_revealed", handleRolesRevealed); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + return { + openFan: openFan, + closeFan: closeFan, + setReload: function (fn) { _reload = fn; }, + }; +}()); diff --git a/src/apps/epic/static/apps/epic/room.js b/src/apps/epic/static/apps/epic/room.js new file mode 100644 index 0000000..07cf756 --- /dev/null +++ b/src/apps/epic/static/apps/epic/room.js @@ -0,0 +1,17 @@ +(function () { + const roomPage = document.querySelector('.room-page'); + if (!roomPage) return; + + const roomId = roomPage.dataset.roomId; + const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`); + + ws.onmessage = function (event) { + const data = JSON.parse(event.data); + window.dispatchEvent(new CustomEvent('room:' + data.type, { detail: data })); + }; + + ws.onclose = function () { + console.warn('Room WebSocket closed'); + }; +}()); diff --git a/src/apps/epic/tests/integrated/test_consumers.py b/src/apps/epic/tests/integrated/test_consumers.py index 53eda18..537883d 100644 --- a/src/apps/epic/tests/integrated/test_consumers.py +++ b/src/apps/epic/tests/integrated/test_consumers.py @@ -15,18 +15,66 @@ TEST_CHANNEL_LAYERS = { @override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) class RoomConsumerTest(SimpleTestCase): async def test_can_connect_and_disconnect(self): - communicator = WebsocketCommunicator(application, "/ws/room/test-room/") + communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/") connected, _ = await communicator.connect() self.assertTrue(connected) await communicator.disconnect() - async def test_receives_gate_update_broadcast(self): - communicator = WebsocketCommunicator(application, "/ws/room/test-room/") + async def test_receives_role_select_start_broadcast(self): + communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/") await communicator.connect() channel_layer = get_channel_layer() await channel_layer.group_send( - "room_test-room", + "room_00000000-0000-0000-0000-000000000001", + {"type": "role_select_start", "slot_order": [1, 2, 3, 4, 5, 6]}, + ) + + response = await communicator.receive_json_from() + self.assertEqual(response["type"], "role_select_start") + self.assertEqual(response["slot_order"], [1, 2, 3, 4, 5, 6]) + + await communicator.disconnect() + + async def test_receives_turn_changed_broadcast(self): + communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/") + await communicator.connect() + + channel_layer = get_channel_layer() + await channel_layer.group_send( + "room_00000000-0000-0000-0000-000000000001", + {"type": "turn_changed", "active_slot": 2}, + ) + + response = await communicator.receive_json_from() + self.assertEqual(response["type"], "turn_changed") + self.assertEqual(response["active_slot"], 2) + + await communicator.disconnect() + + async def test_receives_roles_revealed_broadcast(self): + communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/") + await communicator.connect() + + channel_layer = get_channel_layer() + await channel_layer.group_send( + "room_00000000-0000-0000-0000-000000000001", + {"type": "roles_revealed", "assignments": {"1": "PC", "2": "BC"}}, + ) + + response = await communicator.receive_json_from() + self.assertEqual(response["type"], "roles_revealed") + self.assertIn("assignments", response) + + await communicator.disconnect() + + async def test_receives_gate_update_broadcast(self): + communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/") + await communicator.connect() + + channel_layer = get_channel_layer() + await channel_layer.group_send( + "room_00000000-0000-0000-0000-000000000001", {"type": "gate_update", "gate_state": "some_state"}, ) diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 1fc2357..6e32e65 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -5,7 +5,7 @@ from django.urls import reverse from django.utils import timezone from apps.lyric.models import Token, User -from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token +from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token class RoomCreationTest(TestCase): @@ -132,6 +132,62 @@ class SelectTokenTest(TestCase): self.assertEqual(token.token_type, Token.PASS) +class RoomTableStatusTest(TestCase): + def setUp(self): + self.owner = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Test Room", owner=self.owner) + + def test_table_status_defaults_to_blank(self): + self.room.refresh_from_db() + self.assertFalse(self.room.table_status) + + def test_room_has_role_select_constant(self): + self.assertEqual(Room.ROLE_SELECT, "ROLE_SELECT") + + def test_room_has_sig_select_constant(self): + self.assertEqual(Room.SIG_SELECT, "SIG_SELECT") + + def test_room_has_in_game_constant(self): + self.assertEqual(Room.IN_GAME, "IN_GAME") + + def test_table_status_accepts_role_select(self): + self.room.table_status = Room.ROLE_SELECT + self.room.save() + self.room.refresh_from_db() + self.assertEqual(self.room.table_status, Room.ROLE_SELECT) + + +class TableSeatModelTest(TestCase): + def setUp(self): + self.owner = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Test Room", owner=self.owner) + + def test_table_seat_can_be_created(self): + seat = TableSeat.objects.create( + room=self.room, + gamer=self.owner, + slot_number=1, + ) + self.assertEqual(seat.slot_number, 1) + self.assertIsNone(seat.role) + self.assertFalse(seat.role_revealed) + self.assertIsNone(seat.seat_position) + + def test_table_seat_role_choices_cover_all_six(self): + role_codes = [c[0] for c in TableSeat.ROLE_CHOICES] + for code in ["PC", "BC", "SC", "AC", "NC", "EC"]: + self.assertIn(code, role_codes) + + def test_partner_map_pairs_are_mutual(self): + for a, b in [(TableSeat.PC, TableSeat.BC), (TableSeat.SC, TableSeat.AC), (TableSeat.NC, TableSeat.EC)]: + self.assertEqual(TableSeat.PARTNER_MAP[a], b) + self.assertEqual(TableSeat.PARTNER_MAP[b], a) + + def test_room_table_seats_reverse_relation(self): + TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1) + self.assertEqual(self.room.table_seats.count(), 1) + + class RoomInviteTest(TestCase): def setUp(self): self.founder = User.objects.create(email="founder@example.com") diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 8545199..2ba5ace 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1,10 +1,12 @@ from datetime import timedelta +from unittest.mock import patch + from django.test import TestCase from django.urls import reverse from django.utils import timezone from apps.lyric.models import Token, User -from apps.epic.models import GateSlot, Room, RoomInvite +from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat class RoomCreationViewTest(TestCase): @@ -346,6 +348,298 @@ class ConfirmTokenPriorityViewTest(TestCase): self.assertIsNone(self.coin.current_room) +class RoleSelectRenderingTest(TestCase): + def setUp(self): + self.founder = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Test Room", owner=self.founder) + self.gamers = [self.founder] + for i in range(2, 7): + self.gamers.append(User.objects.create(email=f"g{i}@test.io")) + for i, gamer in enumerate(self.gamers, start=1): + slot = self.room.gate_slots.get(slot_number=i) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + self.room.gate_status = Room.OPEN + self.room.table_status = Room.ROLE_SELECT + self.room.save() + for i, gamer in enumerate(self.gamers, start=1): + TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i) + + def test_room_view_includes_card_stack_when_role_select(self): + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, "card-stack") + + def test_card_stack_eligible_for_slot1_gamer(self): + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, 'data-state="eligible"') + + def test_card_stack_ineligible_for_slot2_gamer(self): + self.client.force_login(self.gamers[1]) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, 'data-state="ineligible"') + + def test_card_stack_ineligible_shows_fa_ban(self): + self.client.force_login(self.gamers[1]) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, "fa-ban") + + def test_card_stack_eligible_omits_fa_ban(self): + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertNotContains(response, "fa-ban") + + def test_gatekeeper_overlay_absent_when_role_select(self): + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertNotContains(response, "gate-overlay") + + def test_six_table_seats_rendered(self): + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, "table-seat", count=6) + + def test_active_table_seat_has_active_class(self): + self.client.force_login(self.founder) # slot 1 is active + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, 'class="table-seat active"') + + def test_inactive_table_seat_lacks_active_class(self): + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + # Slots 2–6 are not active, so at least one plain table-seat exists + self.assertContains(response, 'class="table-seat"') + + def test_card_stack_has_data_user_slots_for_eligible_gamer(self): + self.client.force_login(self.founder) # founder is slot 1 only + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, 'data-user-slots="1"') + + def test_card_stack_has_data_user_slots_for_ineligible_gamer(self): + self.client.force_login(self.gamers[1]) # slot 2 gamer + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, 'data-user-slots="2"') + + +class PickRolesViewTest(TestCase): + def setUp(self): + self.founder = User.objects.create(email="founder@test.io") + self.client.force_login(self.founder) + self.room = Room.objects.create(name="Test Room", owner=self.founder) + for i in range(1, 7): + gamer = self.founder if i == 1 else User.objects.create(email=f"g{i}@test.io") + slot = self.room.gate_slots.get(slot_number=i) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + self.room.gate_status = Room.OPEN + self.room.save() + + def test_pick_roles_transitions_room_to_role_select(self): + self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id})) + self.room.refresh_from_db() + self.assertEqual(self.room.table_status, Room.ROLE_SELECT) + + def test_pick_roles_creates_one_table_seat_per_filled_slot(self): + self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id})) + self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6) + + def test_pick_roles_table_seats_carry_gamer_and_slot_number(self): + self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id})) + seat = TableSeat.objects.get(room=self.room, slot_number=1) + self.assertEqual(seat.gamer, self.founder) + + def test_only_open_room_can_start_role_select(self): + self.room.gate_status = Room.GATHERING + self.room.save() + self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id})) + self.room.refresh_from_db() + self.assertIsNone(self.room.table_status) + + def test_pick_roles_requires_login(self): + self.client.logout() + response = self.client.post( + reverse("epic:pick_roles", kwargs={"room_id": self.room.id}) + ) + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_pick_roles_redirects_to_room(self): + response = self.client.post( + reverse("epic:pick_roles", kwargs={"room_id": self.room.id}) + ) + self.assertRedirects( + response, reverse("epic:gatekeeper", args=[self.room.id]) + ) + + def test_pick_roles_notifies_channel_layer(self): + with patch("apps.epic.views._notify_role_select_start") as mock_notify: + self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id})) + mock_notify.assert_called_once_with(self.room.id) + + +class SelectRoleViewTest(TestCase): + def setUp(self): + self.founder = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Test Room", owner=self.founder) + self.gamers = [self.founder] + for i in range(2, 7): + self.gamers.append(User.objects.create(email=f"g{i}@test.io")) + for i, gamer in enumerate(self.gamers, start=1): + slot = self.room.gate_slots.get(slot_number=i) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + self.room.gate_status = Room.OPEN + self.room.table_status = Room.ROLE_SELECT + self.room.save() + for i, gamer in enumerate(self.gamers, start=1): + TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i) + self.client.force_login(self.founder) + + def test_select_role_records_choice(self): + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "PC"}, + ) + seat = TableSeat.objects.get(room=self.room, slot_number=1) + self.assertEqual(seat.role, "PC") + + def test_select_role_wrong_turn_makes_no_change(self): + self.client.force_login(self.gamers[1]) # slot 2 — not their turn + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "BC"}, + ) + seat = TableSeat.objects.get(room=self.room, slot_number=2) + self.assertIsNone(seat.role) + + def test_turn_advances_after_selection(self): + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "PC"}, + ) + next_active = TableSeat.objects.filter( + room=self.room, role__isnull=True + ).order_by("slot_number").first() + self.assertEqual(next_active.slot_number, 2) + + def test_all_selected_sets_sig_select(self): + roles = ["PC", "BC", "SC", "AC", "NC"] + for i, role in enumerate(roles): + seat = TableSeat.objects.get(room=self.room, slot_number=i + 1) + seat.role = role + seat.save() + self.client.force_login(self.gamers[5]) # slot 6 — last + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "EC"}, + ) + self.room.refresh_from_db() + self.assertEqual(self.room.table_status, Room.SIG_SELECT) + + def test_select_role_notifies_turn_changed(self): + with patch("apps.epic.views._notify_turn_changed") as mock_notify: + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "PC"}, + ) + mock_notify.assert_called_once_with(self.room.id) + + def test_select_role_notifies_roles_revealed_when_last(self): + roles = ["PC", "BC", "SC", "AC", "NC"] + for i, role in enumerate(roles): + seat = TableSeat.objects.get(room=self.room, slot_number=i + 1) + seat.role = role + seat.save() + self.client.force_login(self.gamers[5]) + with patch("apps.epic.views._notify_roles_revealed") as mock_notify: + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "EC"}, + ) + mock_notify.assert_called_once_with(self.room.id) + + def test_select_role_requires_login(self): + self.client.logout() + response = self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "PC"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_select_role_redirects_to_room(self): + response = self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "PC"}, + ) + self.assertRedirects( + response, reverse("epic:gatekeeper", args=[self.room.id]) + ) + + +class RevealPhaseRenderingTest(TestCase): + def setUp(self): + self.founder = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Test Room", owner=self.founder) + gamers = [self.founder] + for i in range(2, 7): + gamers.append(User.objects.create(email=f"g{i}@test.io")) + roles = ["PC", "BC", "SC", "AC", "NC", "EC"] + for i, (gamer, role) in enumerate(zip(gamers, roles), start=1): + TableSeat.objects.create( + room=self.room, gamer=gamer, slot_number=i, + role=role, role_revealed=True, + ) + self.room.gate_status = Room.OPEN + self.room.table_status = Room.SIG_SELECT + self.room.save() + self.client.force_login(self.founder) + + def test_face_up_role_cards_rendered_when_sig_select(self): + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, "face-up") + + def test_inv_role_card_slot_present(self): + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, "id_inv_role_card") + + def test_partner_indicator_present_when_sig_select(self): + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + ) + self.assertContains(response, "partner-indicator") + + 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 bcc24cd..cb86547 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -11,6 +11,8 @@ urlpatterns = [ path('room//gate/confirm_token', views.confirm_token, name='confirm_token'), path('room//gate/return_token', views.return_token, name='return_token'), path('room//gate/release_slot', views.release_slot, name='release_slot'), + path('room//pick-roles', views.pick_roles, name='pick_roles'), + path('room//select-role', views.select_role, name='select_role'), path('room//gate/invite', views.invite_gamer, name='invite_gamer'), path('room//gate/status', views.gate_status, name='gate_status'), path('room//delete', views.delete_room, name='delete_room'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 8654cff..02ce379 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,17 +1,60 @@ from datetime import timedelta +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer 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 GateSlot, Room, RoomInvite, debit_token, select_token +from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token from apps.lyric.models import Token RESERVE_TIMEOUT = timedelta(seconds=60) +def _notify_gate_update(room_id): + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'gate_update'}, + ) + + +def _notify_turn_changed(room_id): + active_seat = TableSeat.objects.filter( + room_id=room_id, role__isnull=True + ).order_by("slot_number").first() + active_slot = active_seat.slot_number if active_seat else None + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'turn_changed', 'active_slot': active_slot}, + ) + + +def _notify_roles_revealed(room_id): + assignments = { + str(seat.slot_number): seat.role + for seat in TableSeat.objects.filter(room_id=room_id).order_by("slot_number") + } + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'roles_revealed', 'assignments': assignments}, + ) + + +def _notify_role_select_start(room_id): + slot_order = list( + GateSlot.objects.filter(room_id=room_id, status=GateSlot.FILLED) + .order_by("slot_number") + .values_list("slot_number", flat=True) + ) + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'role_select_start', 'slot_order': slot_order}, + ) + + def _expire_reserved_slots(room): cutoff = timezone.now() - RESERVE_TIMEOUT room.gate_slots.filter( @@ -79,6 +122,65 @@ def _gate_context(room, user): } +def _role_select_context(room, user): + user_seat = None + active_seat = None + unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number") + if unassigned.exists(): + # Normal path — TableSeats present + active_seat = unassigned.first() + user_seat = None + if user.is_authenticated: + user_seat = room.table_seats.filter(gamer=user, role__isnull=True).order_by("slot_number").first() + if user_seat and user_seat.slot_number == active_seat.slot_number: + card_stack_state = "eligible" + else: + card_stack_state = "ineligible" + else: + # Fallback — no TableSeats yet; use GateSlot drop order + active_slot = room.gate_slots.filter( + status=GateSlot.FILLED + ).order_by("slot_number").first() + if active_slot is None: + card_stack_state = None + elif user.is_authenticated and active_slot.gamer == user: + card_stack_state = "eligible" + else: + card_stack_state = "ineligible" + taken_roles = list( + room.table_seats.exclude(role__isnull=True).values_list("role", flat=True) + ) + _action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])} + assigned_seats = ( + sorted( + room.table_seats.filter(gamer=user, role__isnull=False), + key=lambda s: _action_order.get(s.role, 99), + ) + if user.is_authenticated else [] + ) + active_slot = active_seat.slot_number if active_seat else None + ctx = { + "card_stack_state": card_stack_state, + "taken_roles": taken_roles, + "assigned_seats": assigned_seats, + "user_seat": user_seat, + "user_slots": list( + room.table_seats.filter(gamer=user, role__isnull=True) + .order_by("slot_number") + .values_list("slot_number", flat=True) + ) if user.is_authenticated else [], + "active_slot": active_slot, + } + if room.table_status == Room.SIG_SELECT: + user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None + partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None + partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None + ctx["user_seat"] = user_seat + ctx["partner_seat"] = partner_seat + ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number") + return ctx + + @login_required def create_room(request): if request.method == "POST": @@ -91,7 +193,10 @@ def create_room(request): def gatekeeper(request, room_id): room = Room.objects.get(id=room_id) - ctx = _gate_context(room, request.user) + if room.table_status: + ctx = _role_select_context(room, request.user) + else: + ctx = _gate_context(room, request.user) ctx["room"] = room return render(request, "apps/gameboard/room.html", ctx) @@ -113,6 +218,7 @@ def drop_token(request, room_id): token.current_room = room token.save() request.session["kit_token_id"] = str(token.id) + _notify_gate_update(room_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) @@ -127,6 +233,7 @@ def drop_token(request, room_id): slot.reserved_at = timezone.now() slot.save() request.session["kit_token_id"] = str(token.id) + _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) @@ -150,6 +257,7 @@ def confirm_token(request, room_id): if int(slot_number) > carte.slots_claimed: carte.slots_claimed = int(slot_number) carte.save() + _notify_gate_update(room_id) else: slot = room.gate_slots.filter( gamer=request.user, status=GateSlot.RESERVED @@ -163,6 +271,7 @@ def confirm_token(request, room_id): token = select_token(request.user) if token: debit_token(request.user, slot, token) + _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) @@ -185,6 +294,7 @@ def return_token(request, room_id): carte.slots_claimed = 0 carte.save() request.session.pop("kit_token_id", None) + _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) slot = room.gate_slots.filter( gamer=request.user, @@ -214,6 +324,7 @@ def return_token(request, room_id): slot.debited_token_type = None slot.debited_token_expires_at = None slot.save() + _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) @@ -240,6 +351,52 @@ def release_slot(request, room_id): if room.gate_status == Room.OPEN: room.gate_status = Room.GATHERING room.save() + _notify_gate_update(room_id) + return redirect("epic:gatekeeper", room_id=room_id) + + +@login_required +def select_role(request, room_id): + if request.method == "POST": + room = Room.objects.get(id=room_id) + if room.table_status != Room.ROLE_SELECT: + return redirect("epic:gatekeeper", room_id=room_id) + active_seat = room.table_seats.filter( + role__isnull=True + ).order_by("slot_number").first() + if not active_seat or active_seat.gamer != request.user: + return redirect("epic:gatekeeper", room_id=room_id) + role = request.POST.get("role") + valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES] + if not role or role not in valid_roles: + return redirect("epic:gatekeeper", room_id=room_id) + if room.table_seats.filter(role=role).exists(): + return redirect("epic:gatekeeper", room_id=room_id) + active_seat.role = role + active_seat.save() + if room.table_seats.filter(role__isnull=True).exists(): + _notify_turn_changed(room_id) + else: + room.table_status = Room.SIG_SELECT + room.save() + _notify_roles_revealed(room_id) + return redirect("epic:gatekeeper", room_id=room_id) + + +@login_required +def pick_roles(request, room_id): + if request.method == "POST": + room = Room.objects.get(id=room_id) + if room.gate_status == Room.OPEN: + room.table_status = Room.ROLE_SELECT + room.save() + for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"): + TableSeat.objects.create( + room=room, + gamer=slot.gamer, + slot_number=slot.slot_number, + ) + _notify_role_select_start(room_id) return redirect("epic:gatekeeper", room_id=room_id) diff --git a/src/apps/lyric/migrations/0013_alter_token_slots_claimed.py b/src/apps/lyric/migrations/0013_alter_token_slots_claimed.py new file mode 100644 index 0000000..ceac2ce --- /dev/null +++ b/src/apps/lyric/migrations/0013_alter_token_slots_claimed.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-03-17 01:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0012_carte_slots_claimed'), + ] + + operations = [ + migrations.AlterField( + model_name='token', + name='slots_claimed', + field=models.PositiveSmallIntegerField(blank=True, default=0), + ), + ] diff --git a/src/core/settings.py b/src/core/settings.py index 7c772b7..23714d0 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -118,6 +118,7 @@ else: 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', + 'TEST': {'NAME': BASE_DIR / 'test_db.sqlite3'}, } } @@ -222,3 +223,8 @@ if 'test' in sys.argv: shutil.rmtree(_cache_dir, ignore_errors=True) COMPRESS_CACHE_BACKEND = 'default' TEST_RUNNER = 'core.runner.RobustCompressorTestRunner' + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + } + } diff --git a/src/functional_tests/base.py b/src/functional_tests/base.py index 2133279..fb55841 100644 --- a/src/functional_tests/base.py +++ b/src/functional_tests/base.py @@ -4,6 +4,7 @@ import time from datetime import datetime from django.conf import settings from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from channels.testing import ChannelsLiveServerTestCase from pathlib import Path from selenium import webdriver from selenium.common.exceptions import WebDriverException @@ -122,3 +123,81 @@ class FunctionalTest(StaticLiveServerTestCase): lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name=email]"), navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar") self.assertNotIn(email, navbar.text) + + +class ChannelsFunctionalTest(ChannelsLiveServerTestCase): + """Like FunctionalTest but backed by daphne so WebSocket connections work.""" + serve_static = True + + def setUp(self): + options = webdriver.FirefoxOptions() + headless = os.environ.get("HEADLESS") + if headless: + options.add_argument("--headless") + self.browser = webdriver.Firefox(options=options) + if headless: + self.browser.set_window_size(1366, 900) + self.test_server = os.environ.get("TEST_SERVER") + if self.test_server: + self.live_server_url = 'http://' + self.test_server + reset_database(self.test_server) + Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"}) + + def tearDown(self): + if self._test_has_failed(): + if not SCREEN_DUMP_LOCATION.exists(): + SCREEN_DUMP_LOCATION.mkdir(parents=True) + self.take_screenshot() + self.dump_html() + self.browser.quit() + super().tearDown() + + def _test_has_failed(self): + return any( + failure[0] == self + for failure in self._outcome.result.failures + self._outcome.result.errors + ) + + def take_screenshot(self): + path = SCREEN_DUMP_LOCATION / self._get_filename("png") + print("screendumping to", path) + self.browser.get_screenshot_as_file(str(path)) + + def dump_html(self): + path = SCREEN_DUMP_LOCATION / self._get_filename("html") + print("dumping page html to", path) + path.write_text(self.browser.page_source, encoding="utf-8") + + def _get_filename(self, extension): + timestamp = datetime.now().isoformat().replace(":", ".") + return ( + f"{self.__class__.__name__}.{self._testMethodName}-{timestamp}.{extension}" + ) + + @wait + def wait_for(self, fn): + return fn() + + def wait_for_slow(self, fn, timeout=30): + start_time = time.time() + while True: + try: + return fn() + except (AssertionError, WebDriverException) as e: + if time.time() - start_time > timeout: + raise e + time.sleep(0.5) + + def create_pre_authenticated_session(self, email): + if self.test_server: + session_key = create_session_on_server(self.test_server, email) + else: + session_key = create_pre_authenticated_session(email) + self.browser.get(self.live_server_url + "/404_no_such_url/") + self.browser.add_cookie( + dict( + name=settings.SESSION_COOKIE_NAME, + value=session_key, + path="/", + ) + ) diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index a411aa0..9fff7cc 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -190,7 +190,9 @@ class GatekeeperTest(FunctionalTest): room.refresh_from_db() room.gate_status = Room.OPEN room.save() - # 4. Gate shows launch button via htmx when all slots filled + # 4. Gate shows launch button when all slots filled + # update this for ASGI after channels sprint! + self.browser.refresh() self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn") ) diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py new file mode 100644 index 0000000..8e364fb --- /dev/null +++ b/src/functional_tests/test_room_role_select.py @@ -0,0 +1,567 @@ +from django.conf import settings as django_settings +from selenium import webdriver +from selenium.webdriver.common.by import By + +from .base import FunctionalTest, ChannelsFunctionalTest +from .management.commands.create_session import create_pre_authenticated_session +from apps.applets.models import Applet +from apps.epic.models import Room, GateSlot, TableSeat +from apps.lyric.models import User + + +def _fill_room_via_orm(room, emails): + """Fill all 6 gate slots and set gate_status=OPEN. Returns list of gamers.""" + gamers = [] + for i, email in enumerate(emails, start=1): + gamer, _ = User.objects.get_or_create(email=email) + slot = room.gate_slots.get(slot_number=i) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + gamers.append(gamer) + room.gate_status = Room.OPEN + room.save() + return gamers + + +class RoleSelectTest(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"} + ) + + # ------------------------------------------------------------------ # + # Test 1 — PICK ROLES dismisses gatekeeper and reveals the table # + # ------------------------------------------------------------------ # + + def test_pick_roles_dismisses_gatekeeper_and_reveals_table(self): + # 1. Founder logs in, creates room via UI, fills remaining slots via ORM + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_new_game_name") + ).send_keys("Dragon's Den") + self.browser.find_element(By.ID, "id_create_game_btn").click() + self.wait_for( + lambda: self.assertIn("/gate/", self.browser.current_url) + ) + room_url = self.browser.current_url + room = Room.objects.get(name="Dragon's Den") + + # Fill founder's slot via UI (slot 1) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails") + ).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") + ) + + # Fill slots 2–6 via ORM + emails = ["amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io"] + for i, email in enumerate(emails, start=2): + gamer, _ = User.objects.get_or_create(email=email) + slot = room.gate_slots.get(slot_number=i) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + room.gate_status = Room.OPEN + room.save() + + # 2. Browser sees the PICK ROLES button (gate is now open) + self.browser.refresh() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn") + ).click() + + # 3. Gatekeeper overlay is gone + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-overlay")), 0 + ) + ) + + # 4. Table is visible and prominent + table = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_game_table") + ) + self.assertTrue(table.is_displayed()) + + # 5. Card stack is present in the table centre + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") + ) + + # 6. Six seat portraits are visible around the table + seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat") + self.assertEqual(len(seats), 6) + + # ------------------------------------------------------------------ # + # Test 2 — Card stack signals eligibility to each gamer # + # ------------------------------------------------------------------ # + + def test_card_stack_glows_for_first_gamer_only(self): + # Two browsers: founder (slot 1, eligible) and friend (slot 2, not yet) + founder, _ = User.objects.get_or_create(email="founder@test.io") + friend, _ = User.objects.get_or_create(email="friend@test.io") + room = Room.objects.create(name="Signal Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "friend@test.io", + "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + # Founder's browser + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + stack = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") + ) + self.assertIn("eligible", stack.get_attribute("data-state")) + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, ".card-stack .fa-ban")), 0 + ) + + # Friend's browser + self.browser2 = webdriver.Firefox() + try: + self.browser2.get(self.live_server_url + "/404_no_such_url/") + from django.conf import settings + session_key = __import__( + "functional_tests.management.commands.create_session", + fromlist=["create_pre_authenticated_session"] + ).create_pre_authenticated_session("friend@test.io") + self.browser2.add_cookie(dict( + name=settings.SESSION_COOKIE_NAME, value=session_key, path="/" + )) + self.browser2.get(room_url) + stack2 = self.wait_for( + lambda: self.browser2.find_element(By.CSS_SELECTOR, ".card-stack") + ) + self.assertIn("ineligible", stack2.get_attribute("data-state")) + self.wait_for( + lambda: self.browser2.find_element( + By.CSS_SELECTOR, ".card-stack .fa-ban" + ) + ) + finally: + self.browser2.quit() + + # ------------------------------------------------------------------ # + # Test 3 — Active gamer fans cards, inspects, selects a role # + # ------------------------------------------------------------------ # + + def test_active_gamer_fans_cards_and_selects_role(self): + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Fan Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + + # 1. Click the card stack + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") + ).click() + + # 2. Role Select modal opens with 6 cards + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_role_select") + ) + cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card") + self.assertEqual(len(cards), 6) + + # 3. Blur backdrop is present + self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop") + + # 4. Hover over first card — it flips to reveal front + from selenium.webdriver.common.action_chains import ActionChains + ActionChains(self.browser).move_to_element(cards[0]).perform() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_role_select .card.flipped" + ) + ) + + # 5. Click first card to select it + cards[0].click() + + # 6. Modal closes + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements(By.ID, "id_role_select")), 0 + ) + ) + + # 7. Role card appears in inventory + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_inv_role_card .card" + ) + ) + + # 8. Card stack returns to table centre + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") + ) + + # ------------------------------------------------------------------ # + # Test 3b — Chosen role absent from next gamer's fan # + # ------------------------------------------------------------------ # + + def test_chosen_role_absent_from_next_gamer_fan(self): + from apps.epic.models import TableSeat + founder, _ = User.objects.get_or_create(email="founder@test.io") + friend, _ = User.objects.get_or_create(email="friend@test.io") + room = Room.objects.create(name="Pool Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "friend@test.io", + "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + + # Simulate pick_roles: create a TableSeat per filled slot + for slot in room.gate_slots.order_by("slot_number"): + TableSeat.objects.create( + room=room, gamer=slot.gamer, slot_number=slot.slot_number, + ) + + # Slot 1 (founder) has already chosen PC + TableSeat.objects.filter(room=room, slot_number=1).update(role="PC") + + # Slot 2 (friend) is now the active gamer + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + self.create_pre_authenticated_session("friend@test.io") + self.browser.get(room_url) + + # Card stack is eligible for slot 2 + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" + ) + ).click() + + # Fan opens — only 5 cards (PC is taken) + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_role_select") + ) + cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card") + self.assertEqual(len(cards), 5) + + # Specifically, no PC card in the fan + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, "#id_role_select .card[data-role='PC']" + )), + 0, + ) + + # ------------------------------------------------------------------ # + # Test 3c — Card stack stays eligible after re-entering mid-session # + # ------------------------------------------------------------------ # + + def test_card_stack_remains_eligible_after_re_entering_mid_selection(self): + """A gamer holding multiple slots should still see an eligible card + stack when they re-enter the room after having already chosen a role + for their earlier slot.""" + from apps.epic.models import TableSeat + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Re-entry Test", owner=founder) + # Founder holds slots 1 and 2; others fill the rest + _fill_room_via_orm(room, [ + "founder@test.io", "founder@test.io", + "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + + for slot in room.gate_slots.order_by("slot_number"): + TableSeat.objects.create( + room=room, gamer=slot.gamer, slot_number=slot.slot_number, + ) + # Founder's first slot has already chosen PC + TableSeat.objects.filter(room=room, slot_number=1).update(role="PC") + + # Founder re-enters the room (simulating a page reload / re-navigation) + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + + # Card stack must be eligible — slot 2 (also founder's) is the active seat + stack = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") + ) + self.assertEqual(stack.get_attribute("data-state"), "eligible") + + # Fan shows 5 cards — PC already taken + stack.click() + self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) + cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card") + self.assertEqual(len(cards), 5) + + # ------------------------------------------------------------------ # + # Test 3d — Previously selected roles appear in inventory on re-entry# + # ------------------------------------------------------------------ # + + def test_previously_selected_roles_shown_in_inventory_on_re_entry(self): + """A multi-slot gamer who already chose some roles should see those + role cards pre-populated in the inventory when they re-enter the room.""" + from apps.epic.models import TableSeat + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Inventory Re-entry Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "founder@test.io", + "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + + for slot in room.gate_slots.order_by("slot_number"): + TableSeat.objects.create( + room=room, gamer=slot.gamer, slot_number=slot.slot_number, + ) + # Founder's first slot has already chosen BC + TableSeat.objects.filter(room=room, slot_number=1).update(role="BC") + + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + + # Inventory should contain exactly one pre-rendered card for BC + inv_cards = self.wait_for( + lambda: self.browser.find_elements( + By.CSS_SELECTOR, "#id_inv_role_card .card" + ) + ) + self.assertEqual(len(inv_cards), 1) + self.assertIn( + "BUILDER", + inv_cards[0].text.upper(), + ) + + # ------------------------------------------------------------------ # + # Test 4 — Click-away dismisses fan without selecting # + # ------------------------------------------------------------------ # + + def test_click_away_dismisses_card_fan_without_selecting(self): + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Dismiss Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + + # Open the fan + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_role_select") + ) + + # Click the backdrop (outside the fan) + self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click() + + # Modal closes; stack still present; inventory still empty + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements(By.ID, "id_role_select")), 0 + ) + ) + self.browser.find_element(By.CSS_SELECTOR, ".card-stack") + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, "#id_inv_role_card .card")), + 0 + ) + + + # ------------------------------------------------------------------ # + # Test 7 — All roles revealed simultaneously after all gamers select # + # ------------------------------------------------------------------ # + + def test_roles_revealed_simultaneously_after_all_select(self): + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Reveal Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + + # Assign all roles via ORM (simulating all gamers having chosen) + from apps.epic.models import TableSeat + roles = ["PC", "BC", "SC", "AC", "NC", "EC"] + for i, slot in enumerate(room.gate_slots.order_by("slot_number")): + TableSeat.objects.create( + room=room, + gamer=slot.gamer, + slot_number=slot.slot_number, + role=roles[i], + role_revealed=True, + ) + room.table_status = Room.SIG_SELECT + room.save() + + self.browser.refresh() + + # All role cards in inventory are face-up + face_up_cards = self.wait_for( + lambda: self.browser.find_elements( + By.CSS_SELECTOR, "#id_inv_role_card .card.face-up" + ) + ) + self.assertGreater(len(face_up_cards), 0) + + # Partner indicator is visible + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".partner-indicator") + ) + + +class RoleSelectChannelsTest(ChannelsFunctionalTest): + + 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"} + ) + + # ------------------------------------------------------------------ # + # Test 6 — Observer sees seat arc move via WebSocket # + # ------------------------------------------------------------------ # + + def test_observer_sees_seat_arc_during_selection(self): + founder, _ = User.objects.get_or_create(email="founder@test.io") + User.objects.get_or_create(email="watcher@test.io") + room = Room.objects.create(name="Arc Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "watcher@test.io", + "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + for slot in room.gate_slots.order_by("slot_number"): + TableSeat.objects.create( + room=room, gamer=slot.gamer, slot_number=slot.slot_number, + ) + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + # 1. Watcher loads the room — slot 1 is active on initial render + self.create_pre_authenticated_session("watcher@test.io") + self.browser.get(room_url) + self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" + )) + + # 2. Founder picks a role in second browser + self.browser2 = self._make_browser2("founder@test.io") + try: + self.browser2.get(room_url) + self.wait_for(lambda: self.browser2.find_element( + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" + )) + self.browser2.find_element(By.CSS_SELECTOR, ".card-stack").click() + self.wait_for(lambda: self.browser2.find_element(By.ID, "id_role_select")) + self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() + + # 3. Watcher's seat arc moves to slot 2 — no page refresh + self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" + )) + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" + )), + 0, + ) + finally: + self.browser2.quit() + + def _make_browser2(self, email): + """Spin up a second Firefox, authenticate email, return the browser.""" + session_key = create_pre_authenticated_session(email) + b = webdriver.Firefox() + b.get(self.live_server_url + "/404_no_such_url/") + b.add_cookie(dict( + name=django_settings.SESSION_COOKIE_NAME, + value=session_key, + path="/", + )) + return b + + # ------------------------------------------------------------------ # + # Test 5 — Turn passes to next gamer via WebSocket after selection # + # ------------------------------------------------------------------ # + + def test_turn_passes_after_selection(self): + founder, _ = User.objects.get_or_create(email="founder@test.io") + User.objects.get_or_create(email="friend@test.io") + room = Room.objects.create(name="Turn Test", owner=founder) + _fill_room_via_orm(room, [ + "founder@test.io", "friend@test.io", + "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + for slot in room.gate_slots.order_by("slot_number"): + TableSeat.objects.create( + room=room, gamer=slot.gamer, slot_number=slot.slot_number, + ) + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + # 1. Founder (slot 1) — eligible + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" + )) + + # 2. Friend (slot 2) — ineligible in second browser + self.browser2 = self._make_browser2("friend@test.io") + try: + self.browser2.get(room_url) + self.wait_for(lambda: self.browser2.find_element( + By.CSS_SELECTOR, ".card-stack[data-state='ineligible']" + )) + + # 3. Founder picks a role + self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click() + self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) + self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() + + # 4. Friend's stack becomes eligible via WebSocket — no page refresh + self.wait_for(lambda: self.browser2.find_element( + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" + )) + finally: + self.browser2.quit() diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index 26e0d67..5eaa233 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -85,7 +85,8 @@ // Page-level gear buttons — fixed to viewport bottom-right .gameboard-page, .dashboard-page, -.wallet-page { +.wallet-page, +.room-page { > .gear-btn { position: fixed; bottom: 4.2rem; diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index f5401f7..39fba98 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -217,6 +217,7 @@ body { @media (orientation: portrait) and (max-width: 500px) { body .container { .navbar { + padding: 0 0 0.25rem 0; .navbar-brand h1 { font-size: 1.2rem; } @@ -233,7 +234,7 @@ body { text-align: center; text-align-last: center; letter-spacing: 0.33em; - margin: 0 0 0.5rem; + margin: 0; font-size: 2rem; &#id_dash_wallet { @@ -265,7 +266,7 @@ body { #id_footer { flex-shrink: 0; - height: 5rem; + height: 6rem; display: flex; flex-direction: column; gap: 0.5rem; diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index b69f093..9a8cadb 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -10,15 +10,12 @@ $gate-line: 2px; min-height: 60vh; } -.room-page .gear-btn { - z-index: 101; -} #id_room_menu { - position: absolute; - bottom: 3.5rem; + position: fixed; + bottom: 6.6rem; right: 0.5rem; - z-index: 101; + z-index: 202; background-color: rgba(var(--priUser), 0.95); border: 0.15rem solid rgba(var(--secUser), 1); box-shadow: @@ -41,26 +38,6 @@ html:has(.gate-overlay) { overflow: hidden; } -body:has(.gate-overlay) { - - // Pin gear controls to the visual viewport, - // bypassing iOS 100vh chrome-inclusion bug. - // Offset upward so gear btn clears the kit btn below it. - .room-page .gear-btn { - position: fixed; - bottom: 4.2rem; - right: 0.5rem; - z-index: 202; - } - - #id_room_menu { - position: fixed; - bottom: 6.6rem; - right: 0.5rem; - z-index: 202; - } -} - .gate-overlay { position: fixed; inset: 0; @@ -347,6 +324,309 @@ body:has(.gate-overlay) { } } +// ─── Room shell layout ───────────────────────────────────────────────────── + +.room-shell { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 2rem; + width: 100%; + max-height: 80vh; +} + +// ─── Table hex + seat positions ──────────────────────────────────────────── +// +// .table-hex: regular pointy-top hexagon. +// clip-path polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) +// on a 160×185 container gives equal-length sides (height = width × 2/√3). +// +// Seats use absolute positioning from the .room-table centre. +// $seat-r = 130px — radius to seat centroid +// $seat-r-x = round(130px × sin60°) = 113px — horizontal component +// $seat-r-y = round(130px × cos60°) = 65px — vertical component +// +// Clockwise from top: slots 1→2→3→4→5→6. + +$seat-r: 130px; +$seat-r-x: round($seat-r * 0.866); // 113px +$seat-r-y: round($seat-r * 0.5); // 65px + +.room-table { + flex: 2; + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; +} + +.table-hex { + width: 160px; + height: 185px; + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); + background: rgba(var(--priUser), 0.8); + // box-shadow is clipped by clip-path; use filter instead + filter: drop-shadow(0 0 8px rgba(var(--terUser), 0.25)); + display: flex; + align-items: center; + justify-content: center; +} + +.table-center { + display: flex; + align-items: center; + justify-content: center; +} + +.room-inventory { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(var(--terUser), 0.3) transparent; +} + +.table-seat { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + // Centre the element on its anchor point + transform: translate(-50%, -50%); + + // Clockwise from top — slot drop order during ROLE_SELECT + &[data-slot="1"] { left: 50%; top: calc(50% - #{$seat-r}); } + &[data-slot="2"] { left: calc(50% + #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } + &[data-slot="3"] { left: calc(50% + #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } + &[data-slot="4"] { left: 50%; top: calc(50% + #{$seat-r}); } + &[data-slot="5"] { left: calc(50% - #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } + &[data-slot="6"] { left: calc(50% - #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } + + .seat-portrait { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(var(--terUser), 1); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + opacity: 0.6; + } + + .seat-label { + font-size: 0.65rem; + opacity: 0.5; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Arc of mini cards — visible only on the currently active seat + .seat-card-arc { + display: none; + position: absolute; + width: 18px; + height: 26px; + border-radius: 2px; + border: 1px solid rgba(var(--terUser), 0.7); + background: rgba(var(--quaUser), 0.9); + + // Three fanned cards stacked behind the portrait + &::before, + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: inherit; + background: inherit; + } + &::before { transform: rotate(-18deg) translate(-4px, 2px); } + &::after { transform: rotate( 18deg) translate( 4px, 2px); } + } + + &.active .seat-portrait { + opacity: 1; + border-color: rgba(var(--secUser), 1); + box-shadow: 0 0 0.5rem rgba(var(--ninUser), 0.5); + } + + &.active .seat-card-arc { + display: block; + transform: translateY(-28px); // float above the portrait + } +} + +// ─── Card stack ──────────────────────────────────────────────────────────── + +.card-stack { + width: 60px; + height: 90px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + border: 1px solid rgba(var(--secUser), 1); + background: rgba(var(--terUser), 1); + cursor: default; + transition: box-shadow 0.2s ease; + + &[data-state="eligible"] { + cursor: pointer; + border-color: rgba(var(--terUser), 1); + box-shadow: + 0 0 0.6rem rgba(var(--ninUser), 0.6), + 0 0 1.6rem rgba(var(--secUser), 0.25); + } + + &[data-state="ineligible"] { + opacity: 0.4; + cursor: not-allowed; + } +} + +// ─── Role select modal ───────────────────────────────────────────────────── + +.role-select-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + cursor: pointer; +} + +#id_role_select { + display: flex; + gap: 1rem; + pointer-events: none; + + @media (max-width: 600px) { + display: grid; + grid-template-columns: repeat(3, 80px); + grid-template-rows: repeat(2, 120px); + gap: 0.75rem; + } +} + +// ─── Card component ──────────────────────────────────────────────────────── + +$card-w: 80px; +$card-h: 120px; + +.card { + width: $card-w; + height: $card-h; + border-radius: 6px; + cursor: pointer; + pointer-events: auto; + position: relative; + perspective: 600px; + + .card-back, + .card-front { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: inherit; + border: 2px solid rgba(var(--terUser), 1); + background: rgba(var(--quiUser), 1); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transition: transform 0.35s ease; + } + + .card-back { + transform: rotateY(0deg); + font-size: 1.5rem; + color: rgba(var(--quaUser), 1); + background: rgba(var(--quiUser), 1); + border: 1px solid rgba(var(--terUser), 1); + } + + .card-front { + transform: rotateY(180deg); + padding: 0.5rem; + text-align: center; + + .card-role-name { + font-size: 0.75rem; + color: rgba(var(--quaUser), 1); + text-transform: uppercase; + letter-spacing: 0.05em; + } + } + + &.flipped, + &.face-up { + .card-back { transform: rotateY(-180deg); } + .card-front { transform: rotateY(0deg); } + } +} + +// ─── Inventory role card hand ─────────────────────────────────────────────── +// +// Cards are stacked vertically: only a $strip-height peek of each card below +// the first is visible by default, showing the role name at the top of the +// card face. Hovering any card slides it right to pop it clear of the stack. + +$inv-card-w: 100px; +$inv-card-h: 150px; +$inv-strip: 30px; // visible height of each stacked card after the first + +#id_inv_role_card { + display: flex; + flex-direction: column; + + .card { + width: $inv-card-w; + height: $inv-card-h; + position: relative; + z-index: 1; + flex-shrink: 0; + transition: transform 0.2s ease; + + // Every card after the first overlaps the one above it + & + .card { + margin-top: -($inv-card-h - $inv-strip); + } + + // Role name pinned to the top of the face so it reads in the strip + .card-front { + justify-content: flex-start; + padding-top: 0.4rem; + } + + // Pop the hovered card to the right, above siblings + &:hover { + transform: translateX(1.5rem); + z-index: 10; + } + } +} + +// ─── Partner indicator ───────────────────────────────────────────────────── + +.partner-indicator { + margin-top: 0.5rem; + font-size: 0.75rem; + opacity: 0.6; + text-align: center; +} + // Landscape mobile — aggressively scale down to fit short viewport @media (orientation: landscape) and (max-width: 1023px) { .room-page .gear-btn { diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index 5588fa5..05674c5 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -175,9 +175,9 @@ /* Earthman Palette */ // bark - --priBrk: 182, 103, 98; - --secBrk: 132, 78, 68; - --terBrk: 82, 53, 38; + --priBrk: 162, 103, 98; + --secBrk: 117, 78, 68; + --terBrk: 72, 53, 38; // khaki --priKhk: 195, 176, 145; --secKhk: 145, 126, 95; @@ -396,21 +396,21 @@ /* Monochrome Light Palette */ .palette-monochrome-light { --priUser: var(--sixAdm); /* 240,240,240 — light gray bg */ - --secUser: var(--terNi); /* 100,100,100 — mid-dark text/border */ + --secUser: var(--terPer); /* 100,100,100 — mid-dark text/border */ --terUser: var(--priPer); /* 60,60,60 — dark accent */ --quaUser: var(--priAg); /* 30,30,30 — near-black active */ - --quiUser: var(--sixAdm); /* 133,133,133 — mid-gray action */ + --quiUser: var(--priMst); /* 133,133,133 — mid-gray action */ --sixUser: var(--quiAg); /* 175,175,175 — subtle */ --sepUser: var(--sixAg); /* 240,240,240 — secondary subtle */ - --octUser: var(--terNi); /* 93,95,94 — links */ - --ninUser: var(--terPer); /* 255,251,246 — warm bright highlight */ + --octUser: var(--priNi); /* 93,95,94 — links */ + --ninUser: var(--terNi); /* 255,251,246 — warm bright highlight */ --decUser: var(--terPt); /* 189,190,189 — light mid */ } /* Sepia Palette */ .palette-sepia { --priUser: var(--priCu); /* 46,24,5 — very dark warm brown bg */ --secUser: var(--quiCu); /* 207,173,143 — warm beige text/border */ - --terUser: var(--quiAu); /* 214,186,84 — amber gold accent */ + --terUser: var(--priBpk); /* 214,186,84 — amber gold accent */ --quaUser: var(--quaAg); /* 195,176,145 — warm tan interactive */ --quiUser: var(--quaSwp); /* 95,76,45 — deep khaki */ --sixUser: var(--quaCu); /* 171,112,60 — copper mid */ diff --git a/src/static_src/tests/RoleSelectSpec.js b/src/static_src/tests/RoleSelectSpec.js new file mode 100644 index 0000000..66e2453 --- /dev/null +++ b/src/static_src/tests/RoleSelectSpec.js @@ -0,0 +1,240 @@ +describe("RoleSelect", () => { + let testDiv; + + beforeEach(() => { + testDiv = document.createElement("div"); + testDiv.innerHTML = ` +
+
+
+ `; + document.body.appendChild(testDiv); + window.fetch = jasmine.createSpy("fetch").and.returnValue( + Promise.resolve({ ok: true }) + ); + }); + + afterEach(() => { + RoleSelect.closeFan(); + testDiv.remove(); + }); + + // ------------------------------------------------------------------ // + // openFan() // + // ------------------------------------------------------------------ // + + describe("openFan()", () => { + it("creates .role-select-backdrop in the DOM", () => { + RoleSelect.openFan(); + expect(document.querySelector(".role-select-backdrop")).not.toBeNull(); + }); + + it("creates #id_role_select inside the backdrop", () => { + RoleSelect.openFan(); + expect(document.getElementById("id_role_select")).not.toBeNull(); + }); + + it("renders exactly 6 .card elements", () => { + RoleSelect.openFan(); + const cards = document.querySelectorAll("#id_role_select .card"); + expect(cards.length).toBe(6); + }); + + it("does not open a second backdrop if already open", () => { + RoleSelect.openFan(); + RoleSelect.openFan(); + expect(document.querySelectorAll(".role-select-backdrop").length).toBe(1); + }); + }); + + // ------------------------------------------------------------------ // + // closeFan() // + // ------------------------------------------------------------------ // + + describe("closeFan()", () => { + it("removes .role-select-backdrop from the DOM", () => { + RoleSelect.openFan(); + RoleSelect.closeFan(); + expect(document.querySelector(".role-select-backdrop")).toBeNull(); + }); + + it("removes #id_role_select from the DOM", () => { + RoleSelect.openFan(); + RoleSelect.closeFan(); + expect(document.getElementById("id_role_select")).toBeNull(); + }); + + it("does not throw if no fan is open", () => { + expect(() => RoleSelect.closeFan()).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------ // + // Card interactions // + // ------------------------------------------------------------------ // + + describe("card interactions", () => { + beforeEach(() => { + RoleSelect.openFan(); + }); + + it("mouseenter adds .flipped to the card", () => { + const card = document.querySelector("#id_role_select .card"); + card.dispatchEvent(new MouseEvent("mouseenter")); + expect(card.classList.contains("flipped")).toBe(true); + }); + + it("mouseleave removes .flipped from the card", () => { + const card = document.querySelector("#id_role_select .card"); + card.dispatchEvent(new MouseEvent("mouseenter")); + card.dispatchEvent(new MouseEvent("mouseleave")); + expect(card.classList.contains("flipped")).toBe(false); + }); + + it("clicking a card closes the fan", () => { + const card = document.querySelector("#id_role_select .card"); + card.click(); + expect(document.getElementById("id_role_select")).toBeNull(); + }); + + it("clicking a card appends a .card to #id_inv_role_card", () => { + const card = document.querySelector("#id_role_select .card"); + card.click(); + expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull(); + }); + + it("clicking a card POSTs to the select_role URL", () => { + const card = document.querySelector("#id_role_select .card"); + card.click(); + expect(window.fetch).toHaveBeenCalledWith( + "/epic/room/test-uuid/select-role", + jasmine.objectContaining({ method: "POST" }) + ); + }); + + it("clicking a card results in exactly one card in inventory", () => { + const card = document.querySelector("#id_role_select .card"); + card.click(); + expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1); + }); + }); + + // ------------------------------------------------------------------ // + // Backdrop click // + // ------------------------------------------------------------------ // + + describe("backdrop click", () => { + it("closes the fan", () => { + RoleSelect.openFan(); + document.querySelector(".role-select-backdrop").click(); + expect(document.getElementById("id_role_select")).toBeNull(); + }); + + it("does not add a card to inventory", () => { + RoleSelect.openFan(); + document.querySelector(".role-select-backdrop").click(); + expect(document.querySelector("#id_inv_role_card .card")).toBeNull(); + }); + }); + + // ------------------------------------------------------------------ // + // room:roles_revealed event // + // ------------------------------------------------------------------ // + + describe("room:roles_revealed event", () => { + let reloadCalled; + + beforeEach(() => { + reloadCalled = false; + RoleSelect.setReload(() => { reloadCalled = true; }); + }); + + afterEach(() => { + RoleSelect.setReload(() => { window.location.reload(); }); + }); + + it("triggers a page reload", () => { + window.dispatchEvent(new CustomEvent("room:roles_revealed", { detail: {} })); + expect(reloadCalled).toBe(true); + }); + }); + + // ------------------------------------------------------------------ // + // room:turn_changed event // + // ------------------------------------------------------------------ // + + describe("room:turn_changed event", () => { + let stack; + + beforeEach(() => { + // Six table seats, slot 1 starts active + for (let i = 1; i <= 6; i++) { + const seat = document.createElement("div"); + seat.className = "table-seat" + (i === 1 ? " active" : ""); + seat.dataset.slot = String(i); + seat.innerHTML = '
'; + testDiv.appendChild(seat); + } + stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "ineligible"; + stack.dataset.userSlots = "1"; + stack.dataset.takenRoles = ""; + testDiv.appendChild(stack); + }); + + it("moves .active to the newly active seat", () => { + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2 } + })); + expect( + testDiv.querySelector(".table-seat.active").dataset.slot + ).toBe("2"); + }); + + it("removes .active from the previously active seat", () => { + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2 } + })); + expect( + testDiv.querySelector(".table-seat[data-slot='1']").classList.contains("active") + ).toBe(false); + }); + + it("sets data-state to eligible when active_slot matches user slot", () => { + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 1 } + })); + expect(stack.dataset.state).toBe("eligible"); + }); + + it("sets data-state to ineligible when active_slot does not match", () => { + stack.dataset.state = "eligible"; + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2 } + })); + expect(stack.dataset.state).toBe("ineligible"); + }); + + it("clicking stack opens fan when newly eligible", () => { + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 1 } + })); + stack.click(); + expect(document.querySelector(".role-select-backdrop")).not.toBeNull(); + }); + + it("clicking stack does not open fan when ineligible", () => { + // Make eligible first (adds listener), then flip back to ineligible + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 1 } + })); + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2 } + })); + stack.click(); + expect(document.querySelector(".role-select-backdrop")).toBeNull(); + }); + }); +}); diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 329bfa1..6ac0515 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -19,8 +19,10 @@ + - + + diff --git a/src/templates/apps/dashboard/_partials/_scripts.html b/src/templates/apps/dashboard/_partials/_scripts.html index 76ac717..dc9d514 100644 --- a/src/templates/apps/dashboard/_partials/_scripts.html +++ b/src/templates/apps/dashboard/_partials/_scripts.html @@ -1,4 +1,5 @@ - +{% load static %} + - + {% endblock content %} diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 12e268e..14c5729 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -1,8 +1,6 @@
{% if room.gate_status == 'OPEN' %} - +
+ {% csrf_token %} + +
{% endif %} {% if request.user == room.owner %} diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index c4c1b60..5e8935f 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -1,18 +1,74 @@ {% extends "core/base.html" %} +{% load static %} {% block title_text %}Gameboard{% endblock title_text %} {% block header_text %}Gameroom{% endblock header_text %} {% block content %} -
+
- {% comment "game room content" %}gaussian blur + darkening (cf., e.g., tooltip effect) {% endcomment %} -
+
+
+
+ {% if room.table_status == "ROLE_SELECT" and card_stack_state %} +
+ {% if card_stack_state == "ineligible" %} + + {% endif %} +
+ {% endif %} +
+
+ {% for slot in room.gate_slots.all %} +
+
{{ slot.slot_number }}
+
+ + {% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %} + +
+ {% endfor %} +
+
+
+ {% if room.table_status == "ROLE_SELECT" %} + {% for seat in assigned_seats %} +
+
?
+
+
{{ seat.get_role_display }}
+
+
+ {% endfor %} + {% elif room.table_status == "SIG_SELECT" and user_seat %} +
+
+
{{ user_seat.get_role_display }}
+
+
+ {% if partner_seat %} +
+ Partner: {{ partner_seat.get_role_display }} +
+ {% endif %} + {% endif %} +
+
- {% if room.gate_status == "GATHERING" or room.gate_status == "OPEN" %} + {% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %} {% endif %} {% include "apps/gameboard/_partials/_room_gear.html" %}
-{% endblock content %} \ No newline at end of file +{% endblock content %} + +{% block scripts %} + + + +{% endblock scripts %} \ No newline at end of file diff --git a/src/templates/core/base.html b/src/templates/core/base.html index 64e8615..7b87ab5 100644 --- a/src/templates/core/base.html +++ b/src/templates/core/base.html @@ -60,8 +60,8 @@ {% block scripts %} {% endblock scripts %} - - + +