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

This commit is contained in:
Disco DeDisco
2026-03-17 00:24:23 -04:00
parent c9defa5a81
commit 01de6e7548
32 changed files with 2148 additions and 63 deletions

View File

@@ -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)

View File

@@ -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')),
],
),
]

View File

@@ -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)

View File

@@ -4,5 +4,5 @@ from . import consumers
websocket_urlpatterns = [
path('ws/room/<slug:room_slug>/', consumers.RoomConsumer.as_asgi()),
path('ws/room/<uuid:room_id>/', consumers.RoomConsumer.as_asgi()),
]

View File

@@ -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;
});
});
}());

View File

@@ -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 = '<div class="card-role-name">' + role.name + "</div>";
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; },
};
}());

View File

@@ -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');
};
}());

View File

@@ -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"},
)

View File

@@ -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")

View File

@@ -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 26 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")

View File

@@ -11,6 +11,8 @@ urlpatterns = [
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
path('room/<uuid:room_id>/pick-roles', views.pick_roles, name='pick_roles'),
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),

View File

@@ -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)

View File

@@ -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),
),
]

View File

@@ -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',
}
}

View File

@@ -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="/",
)
)

View File

@@ -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")
)

View File

@@ -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 26 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()

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 */

View File

@@ -0,0 +1,240 @@
describe("RoleSelect", () => {
let testDiv;
beforeEach(() => {
testDiv = document.createElement("div");
testDiv.innerHTML = `
<div class="room-page"
data-select-role-url="/epic/room/test-uuid/select-role">
</div>
<div id="id_inv_role_card"></div>
`;
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 = '<div class="seat-card-arc"></div>';
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();
});
});
});

View File

@@ -19,8 +19,10 @@
<script src="lib/jasmine-6.0.1/boot0.js"></script>
<!-- spec files -->
<script src="Spec.js"></script>
<script src="RoleSelectSpec.js"></script>
<!-- src files -->
<script src="/static/apps/scripts/dashboard.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/epic/role-select.js"></script>
<!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script>

View File

@@ -1,4 +1,5 @@
<script src="/static/apps/scripts/dashboard.js"></script>
{% load static %}
<script src="{% static "apps/dashboard/dashboard.js" %}"></script>
<script>
window.onload = () => {
initialize("#id_text");

View File

@@ -11,5 +11,5 @@
</div>
<div id="id_tooltip_portal" class="token-tooltip"></div>
<script src="https://js.stripe.com/v3/"></script>
<script src="{% static "apps/scripts/wallet.js" %}"></script>
<script src="{% static "apps/dashboard/wallet.js" %}"></script>
{% endblock content %}

View File

@@ -1,8 +1,6 @@
<div
id="id_gate_wrapper"
hx-get="{% url 'epic:gate_status' room.id %}"
hx-trigger="every 3s [!document.activeElement.closest('#id_gate_wrapper')]"
hx-swap="outerHTML"
data-gate-status-url="{% url 'epic:gate_status' room.id %}"
>
<div class="gate-overlay">
<div class="gate-modal" role="dialog" aria-label="Gatekeeper">
@@ -83,7 +81,10 @@
{% endfor %}
</div>
{% if room.gate_status == 'OPEN' %}
<button class="launch-game-btn btn btn-primary btn-xl">PICK ROLES</button>
<form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents">
{% csrf_token %}
<button type="submit" class="launch-game-btn btn btn-primary btn-xl">PICK ROLES</button>
</form>
{% endif %}
{% if request.user == room.owner %}

View File

@@ -1,18 +1,74 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}Gameboard{% endblock title_text %}
{% block header_text %}<span>Game</span>room{% endblock header_text %}
{% block content %}
<div class="room-page">
<div class="room-page" data-room-id="{{ room.id }}"
{% if room.table_status %}data-select-role-url="{% url 'epic:select_role' room.id %}"{% endif %}>
<div class="room-shell">
{% comment "game room content" %}gaussian blur + darkening (cf., e.g., tooltip effect) {% endcomment %}
<div class="room-table"></div>
<div id="id_game_table" class="room-table">
<div class="table-hex">
<div class="table-center">
{% if room.table_status == "ROLE_SELECT" and card_stack_state %}
<div class="card-stack" data-state="{{ card_stack_state }}"
data-taken-roles="{{ taken_roles|join:',' }}"
data-user-slots="{{ user_slots|join:',' }}">
{% if card_stack_state == "ineligible" %}
<i class="fa-solid fa-ban"></i>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% for slot in room.gate_slots.all %}
<div class="table-seat{% if slot.slot_number == active_slot %} active{% endif %}"
data-slot="{{ slot.slot_number }}">
<div class="seat-portrait">{{ slot.slot_number }}</div>
<div class="seat-card-arc"></div>
<span class="seat-label">
{% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %}
</span>
</div>
{% endfor %}
</div>
<div id="id_inventory" class="room-inventory">
<div id="id_inv_role_card">
{% if room.table_status == "ROLE_SELECT" %}
{% for seat in assigned_seats %}
<div class="card flipped">
<div class="card-back">?</div>
<div class="card-front">
<div class="card-role-name">{{ seat.get_role_display }}</div>
</div>
</div>
{% endfor %}
{% elif room.table_status == "SIG_SELECT" and user_seat %}
<div class="card face-up">
<div class="card-front">
<div class="card-role-name">{{ user_seat.get_role_display }}</div>
</div>
</div>
{% if partner_seat %}
<div class="partner-indicator">
Partner: {{ partner_seat.get_role_display }}
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% 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" %}
</div>
{% endblock content %}
{% block scripts %}
<script src="{% static 'apps/epic/room.js' %}"></script>
<script src="{% static 'apps/epic/gatekeeper.js' %}"></script>
<script src="{% static 'apps/epic/role-select.js' %}"></script>
{% endblock scripts %}

View File

@@ -60,8 +60,8 @@
{% block scripts %}
{% endblock scripts %}
<script src="{% static "vendor/htmx.min.js" %}"></script>
<script src="{% static "apps/scripts/applets.js" %}"></script>
<script src="{% static "apps/scripts/game-kit.js" %}"></script>
<script src="{% static "apps/applets/applets.js" %}"></script>
<script src="{% static "apps/dashboard/game-kit.js" %}"></script>
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');