Compare commits
3 Commits
462155f07b
...
1558bb02b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1558bb02b4 | ||
|
|
01de6e7548 | ||
|
|
c9defa5a81 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,9 +10,8 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
*.sqlite3
|
||||||
db.sqlite3-journal
|
*.sqlite3-journal
|
||||||
container.db.sqlite3
|
|
||||||
media
|
media
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ channels-redis
|
|||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
coverage
|
coverage
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
dj-database-url
|
dj-database-url
|
||||||
Django==6.0
|
Django==6.0
|
||||||
django-compressor
|
django-compressor
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ celery
|
|||||||
channels
|
channels
|
||||||
channels-redis
|
channels-redis
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
Django==6.0
|
Django==6.0
|
||||||
dj-database-url
|
dj-database-url
|
||||||
django-compressor
|
django-compressor
|
||||||
|
|||||||
@@ -1 +1,27 @@
|
|||||||
# RoomConsumer goes here
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
|
|
||||||
|
|
||||||
|
class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
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()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive_json(self, content):
|
||||||
|
pass # handlers added as events introduced
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
33
src/apps/epic/migrations/0006_table_status_and_table_seat.py
Normal file
33
src/apps/epic/migrations/0006_table_status_and_table_seat.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -29,6 +29,15 @@ class Room(models.Model):
|
|||||||
(INVITE_ONLY, "Invite Only"),
|
(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)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
@@ -36,6 +45,9 @@ class Room(models.Model):
|
|||||||
)
|
)
|
||||||
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
|
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
|
||||||
gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
|
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))
|
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
board_state = models.JSONField(default=dict)
|
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():
|
if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
|
||||||
room.gate_status = Room.OPEN
|
room.gate_status = Room.OPEN
|
||||||
room.save()
|
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)
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
websocket_urlpatterns = []
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path('ws/room/<uuid:room_id>/', consumers.RoomConsumer.as_asgi()),
|
||||||
|
]
|
||||||
|
|||||||
12
src/apps/epic/static/apps/epic/gatekeeper.js
Normal file
12
src/apps/epic/static/apps/epic/gatekeeper.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
163
src/apps/epic/static/apps/epic/role-select.js
Normal file
163
src/apps/epic/static/apps/epic/role-select.js
Normal 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; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
17
src/apps/epic/static/apps/epic/room.js
Normal file
17
src/apps/epic/static/apps/epic/room.js
Normal 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');
|
||||||
|
};
|
||||||
|
}());
|
||||||
85
src/apps/epic/tests/integrated/test_consumers.py
Normal file
85
src/apps/epic/tests/integrated/test_consumers.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from channels.testing.websocket import WebsocketCommunicator
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.test import SimpleTestCase, override_settings
|
||||||
|
|
||||||
|
from core.asgi import application
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CHANNEL_LAYERS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class RoomConsumerTest(SimpleTestCase):
|
||||||
|
async def test_can_connect_and_disconnect(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
connected, _ = await communicator.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
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_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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response["type"], "gate_update")
|
||||||
|
self.assertEqual(response["gate_state"], "some_state")
|
||||||
|
|
||||||
|
await communicator.disconnect()
|
||||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.lyric.models import Token, User
|
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):
|
class RoomCreationTest(TestCase):
|
||||||
@@ -132,6 +132,62 @@ class SelectTokenTest(TestCase):
|
|||||||
self.assertEqual(token.token_type, Token.PASS)
|
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):
|
class RoomInviteTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.founder = User.objects.create(email="founder@example.com")
|
self.founder = User.objects.create(email="founder@example.com")
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.lyric.models import Token, User
|
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):
|
class RoomCreationViewTest(TestCase):
|
||||||
@@ -346,6 +348,298 @@ class ConfirmTokenPriorityViewTest(TestCase):
|
|||||||
self.assertIsNone(self.coin.current_room)
|
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):
|
class RoomActionsViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.owner = User.objects.create(email="owner@test.io")
|
self.owner = User.objects.create(email="owner@test.io")
|
||||||
|
|||||||
@@ -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/confirm_token', views.confirm_token, name='confirm_token'),
|
||||||
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
||||||
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
|
path('room/<uuid:room_id>/gate/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/invite', views.invite_gamer, name='invite_gamer'),
|
||||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||||
|
|||||||
@@ -1,17 +1,60 @@
|
|||||||
from datetime import timedelta
|
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.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.utils import timezone
|
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
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
|
|
||||||
RESERVE_TIMEOUT = timedelta(seconds=60)
|
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):
|
def _expire_reserved_slots(room):
|
||||||
cutoff = timezone.now() - RESERVE_TIMEOUT
|
cutoff = timezone.now() - RESERVE_TIMEOUT
|
||||||
room.gate_slots.filter(
|
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
|
@login_required
|
||||||
def create_room(request):
|
def create_room(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -91,7 +193,10 @@ def create_room(request):
|
|||||||
|
|
||||||
def gatekeeper(request, room_id):
|
def gatekeeper(request, room_id):
|
||||||
room = Room.objects.get(id=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
|
ctx["room"] = room
|
||||||
return render(request, "apps/gameboard/room.html", ctx)
|
return render(request, "apps/gameboard/room.html", ctx)
|
||||||
|
|
||||||
@@ -113,6 +218,7 @@ def drop_token(request, room_id):
|
|||||||
token.current_room = room
|
token.current_room = room
|
||||||
token.save()
|
token.save()
|
||||||
request.session["kit_token_id"] = str(token.id)
|
request.session["kit_token_id"] = str(token.id)
|
||||||
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
|
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
@@ -127,6 +233,7 @@ def drop_token(request, room_id):
|
|||||||
slot.reserved_at = timezone.now()
|
slot.reserved_at = timezone.now()
|
||||||
slot.save()
|
slot.save()
|
||||||
request.session["kit_token_id"] = str(token.id)
|
request.session["kit_token_id"] = str(token.id)
|
||||||
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=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:
|
if int(slot_number) > carte.slots_claimed:
|
||||||
carte.slots_claimed = int(slot_number)
|
carte.slots_claimed = int(slot_number)
|
||||||
carte.save()
|
carte.save()
|
||||||
|
_notify_gate_update(room_id)
|
||||||
else:
|
else:
|
||||||
slot = room.gate_slots.filter(
|
slot = room.gate_slots.filter(
|
||||||
gamer=request.user, status=GateSlot.RESERVED
|
gamer=request.user, status=GateSlot.RESERVED
|
||||||
@@ -163,6 +271,7 @@ def confirm_token(request, room_id):
|
|||||||
token = select_token(request.user)
|
token = select_token(request.user)
|
||||||
if token:
|
if token:
|
||||||
debit_token(request.user, slot, token)
|
debit_token(request.user, slot, token)
|
||||||
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=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.slots_claimed = 0
|
||||||
carte.save()
|
carte.save()
|
||||||
request.session.pop("kit_token_id", None)
|
request.session.pop("kit_token_id", None)
|
||||||
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
slot = room.gate_slots.filter(
|
slot = room.gate_slots.filter(
|
||||||
gamer=request.user,
|
gamer=request.user,
|
||||||
@@ -214,6 +324,7 @@ def return_token(request, room_id):
|
|||||||
slot.debited_token_type = None
|
slot.debited_token_type = None
|
||||||
slot.debited_token_expires_at = None
|
slot.debited_token_expires_at = None
|
||||||
slot.save()
|
slot.save()
|
||||||
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=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:
|
if room.gate_status == Room.OPEN:
|
||||||
room.gate_status = Room.GATHERING
|
room.gate_status = Room.GATHERING
|
||||||
room.save()
|
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)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
src/apps/lyric/migrations/0013_alter_token_slots_claimed.py
Normal file
18
src/apps/lyric/migrations/0013_alter_token_slots_claimed.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -118,6 +118,7 @@ else:
|
|||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'db.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)
|
shutil.rmtree(_cache_dir, ignore_errors=True)
|
||||||
COMPRESS_CACHE_BACKEND = 'default'
|
COMPRESS_CACHE_BACKEND = 'default'
|
||||||
TEST_RUNNER = 'core.runner.RobustCompressorTestRunner'
|
TEST_RUNNER = 'core.runner.RobustCompressorTestRunner'
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'channels.layers.InMemoryChannelLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||||
|
from channels.testing import ChannelsLiveServerTestCase
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.common.exceptions import WebDriverException
|
from selenium.common.exceptions import WebDriverException
|
||||||
@@ -122,3 +123,81 @@ class FunctionalTest(StaticLiveServerTestCase):
|
|||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name=email]"),
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name=email]"),
|
||||||
navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||||||
self.assertNotIn(email, navbar.text)
|
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="/",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ class GatekeeperTest(FunctionalTest):
|
|||||||
room.refresh_from_db()
|
room.refresh_from_db()
|
||||||
room.gate_status = Room.OPEN
|
room.gate_status = Room.OPEN
|
||||||
room.save()
|
room.save()
|
||||||
# 4. 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(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".launch-game-btn")
|
||||||
)
|
)
|
||||||
|
|||||||
567
src/functional_tests/test_room_role_select.py
Normal file
567
src/functional_tests/test_room_role_select.py
Normal 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 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()
|
||||||
@@ -85,7 +85,8 @@
|
|||||||
// Page-level gear buttons — fixed to viewport bottom-right
|
// Page-level gear buttons — fixed to viewport bottom-right
|
||||||
.gameboard-page,
|
.gameboard-page,
|
||||||
.dashboard-page,
|
.dashboard-page,
|
||||||
.wallet-page {
|
.wallet-page,
|
||||||
|
.room-page {
|
||||||
> .gear-btn {
|
> .gear-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 4.2rem;
|
bottom: 4.2rem;
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ body {
|
|||||||
@media (orientation: portrait) and (max-width: 500px) {
|
@media (orientation: portrait) and (max-width: 500px) {
|
||||||
body .container {
|
body .container {
|
||||||
.navbar {
|
.navbar {
|
||||||
|
padding: 0 0 0.25rem 0;
|
||||||
.navbar-brand h1 {
|
.navbar-brand h1 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
@@ -233,7 +234,7 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
text-align-last: center;
|
text-align-last: center;
|
||||||
letter-spacing: 0.33em;
|
letter-spacing: 0.33em;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|
||||||
&#id_dash_wallet {
|
&#id_dash_wallet {
|
||||||
@@ -265,7 +266,7 @@ body {
|
|||||||
|
|
||||||
#id_footer {
|
#id_footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 5rem;
|
height: 6rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -10,15 +10,12 @@ $gate-line: 2px;
|
|||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-page .gear-btn {
|
|
||||||
z-index: 101;
|
|
||||||
}
|
|
||||||
|
|
||||||
#id_room_menu {
|
#id_room_menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
bottom: 3.5rem;
|
bottom: 6.6rem;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
z-index: 101;
|
z-index: 202;
|
||||||
background-color: rgba(var(--priUser), 0.95);
|
background-color: rgba(var(--priUser), 0.95);
|
||||||
border: 0.15rem solid rgba(var(--secUser), 1);
|
border: 0.15rem solid rgba(var(--secUser), 1);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -41,26 +38,6 @@ html:has(.gate-overlay) {
|
|||||||
overflow: hidden;
|
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 {
|
.gate-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -148,10 +125,14 @@ body:has(.gate-overlay) {
|
|||||||
|
|
||||||
&.ready {
|
&.ready {
|
||||||
border-color: rgba(var(--terUser), 1);
|
border-color: rgba(var(--terUser), 1);
|
||||||
box-shadow:
|
|
||||||
0 0 0.6rem rgba(var(--terUser), 0.6),
|
button.token-rails {
|
||||||
0 0 1.6rem rgba(var(--terUser), 0.25)
|
box-shadow:
|
||||||
;
|
0 0 0.6rem rgba(var(--terUser), 0.6),
|
||||||
|
0 0 1.6rem rgba(var(--terUser), 0.25)
|
||||||
|
;
|
||||||
|
.rail { background: rgba(var(--terUser), 1); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.pending,
|
&.pending,
|
||||||
@@ -347,6 +328,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
|
// Landscape mobile — aggressively scale down to fit short viewport
|
||||||
@media (orientation: landscape) and (max-width: 1023px) {
|
@media (orientation: landscape) and (max-width: 1023px) {
|
||||||
.room-page .gear-btn {
|
.room-page .gear-btn {
|
||||||
|
|||||||
@@ -175,9 +175,9 @@
|
|||||||
|
|
||||||
/* Earthman Palette */
|
/* Earthman Palette */
|
||||||
// bark
|
// bark
|
||||||
--priBrk: 182, 103, 98;
|
--priBrk: 162, 103, 98;
|
||||||
--secBrk: 132, 78, 68;
|
--secBrk: 117, 78, 68;
|
||||||
--terBrk: 82, 53, 38;
|
--terBrk: 72, 53, 38;
|
||||||
// khaki
|
// khaki
|
||||||
--priKhk: 195, 176, 145;
|
--priKhk: 195, 176, 145;
|
||||||
--secKhk: 145, 126, 95;
|
--secKhk: 145, 126, 95;
|
||||||
@@ -396,21 +396,21 @@
|
|||||||
/* Monochrome Light Palette */
|
/* Monochrome Light Palette */
|
||||||
.palette-monochrome-light {
|
.palette-monochrome-light {
|
||||||
--priUser: var(--sixAdm); /* 240,240,240 — light gray bg */
|
--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 */
|
--terUser: var(--priPer); /* 60,60,60 — dark accent */
|
||||||
--quaUser: var(--priAg); /* 30,30,30 — near-black active */
|
--quaUser: var(--priAg); /* 30,30,30 — near-black active */
|
||||||
--quiUser: var(--sixAdm); /* 133,133,133 — mid-gray action */
|
--quiUser: var(--priMst); /* 133,133,133 — mid-gray action */
|
||||||
--sixUser: var(--quiAg); /* 175,175,175 — subtle */
|
--sixUser: var(--quiAg); /* 175,175,175 — subtle */
|
||||||
--sepUser: var(--sixAg); /* 240,240,240 — secondary subtle */
|
--sepUser: var(--sixAg); /* 240,240,240 — secondary subtle */
|
||||||
--octUser: var(--terNi); /* 93,95,94 — links */
|
--octUser: var(--priNi); /* 93,95,94 — links */
|
||||||
--ninUser: var(--terPer); /* 255,251,246 — warm bright highlight */
|
--ninUser: var(--terNi); /* 255,251,246 — warm bright highlight */
|
||||||
--decUser: var(--terPt); /* 189,190,189 — light mid */
|
--decUser: var(--terPt); /* 189,190,189 — light mid */
|
||||||
}
|
}
|
||||||
/* Sepia Palette */
|
/* Sepia Palette */
|
||||||
.palette-sepia {
|
.palette-sepia {
|
||||||
--priUser: var(--priCu); /* 46,24,5 — very dark warm brown bg */
|
--priUser: var(--priCu); /* 46,24,5 — very dark warm brown bg */
|
||||||
--secUser: var(--quiCu); /* 207,173,143 — warm beige text/border */
|
--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 */
|
--quaUser: var(--quaAg); /* 195,176,145 — warm tan interactive */
|
||||||
--quiUser: var(--quaSwp); /* 95,76,45 — deep khaki */
|
--quiUser: var(--quaSwp); /* 95,76,45 — deep khaki */
|
||||||
--sixUser: var(--quaCu); /* 171,112,60 — copper mid */
|
--sixUser: var(--quaCu); /* 171,112,60 — copper mid */
|
||||||
|
|||||||
240
src/static_src/tests/RoleSelectSpec.js
Normal file
240
src/static_src/tests/RoleSelectSpec.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,8 +19,10 @@
|
|||||||
<script src="lib/jasmine-6.0.1/boot0.js"></script>
|
<script src="lib/jasmine-6.0.1/boot0.js"></script>
|
||||||
<!-- spec files -->
|
<!-- spec files -->
|
||||||
<script src="Spec.js"></script>
|
<script src="Spec.js"></script>
|
||||||
|
<script src="RoleSelectSpec.js"></script>
|
||||||
<!-- src files -->
|
<!-- 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) -->
|
<!-- Jasmine env config (optional) -->
|
||||||
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script src="/static/apps/scripts/dashboard.js"></script>
|
{% load static %}
|
||||||
|
<script src="{% static "apps/dashboard/dashboard.js" %}"></script>
|
||||||
<script>
|
<script>
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
initialize("#id_text");
|
initialize("#id_text");
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
<div id="id_tooltip_portal" class="token-tooltip"></div>
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<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 %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
id="id_gate_wrapper"
|
id="id_gate_wrapper"
|
||||||
hx-get="{% url 'epic:gate_status' room.id %}"
|
data-gate-status-url="{% url 'epic:gate_status' room.id %}"
|
||||||
hx-trigger="every 3s [!document.activeElement.closest('#id_gate_wrapper')]"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
>
|
||||||
<div class="gate-overlay">
|
<div class="gate-overlay">
|
||||||
<div class="gate-modal" role="dialog" aria-label="Gatekeeper">
|
<div class="gate-modal" role="dialog" aria-label="Gatekeeper">
|
||||||
@@ -83,7 +81,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if room.gate_status == 'OPEN' %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if request.user == room.owner %}
|
{% if request.user == room.owner %}
|
||||||
|
|||||||
@@ -1,18 +1,74 @@
|
|||||||
{% extends "core/base.html" %}
|
{% extends "core/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title_text %}Gameboard{% endblock title_text %}
|
{% block title_text %}Gameboard{% endblock title_text %}
|
||||||
{% block header_text %}<span>Game</span>room{% endblock header_text %}
|
{% block header_text %}<span>Game</span>room{% endblock header_text %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="room-shell">
|
||||||
{% comment "game room content" %}gaussian blur + darkening (cf., e.g., tooltip effect) {% endcomment %}
|
<div id="id_game_table" class="room-table">
|
||||||
<div class="room-table"></div>
|
<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>
|
</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" %}
|
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% 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 %}
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
<script src="{% static "vendor/htmx.min.js" %}"></script>
|
<script src="{% static "vendor/htmx.min.js" %}"></script>
|
||||||
<script src="{% static "apps/scripts/applets.js" %}"></script>
|
<script src="{% static "apps/applets/applets.js" %}"></script>
|
||||||
<script src="{% static "apps/scripts/game-kit.js" %}"></script>
|
<script src="{% static "apps/dashboard/game-kit.js" %}"></script>
|
||||||
<script>
|
<script>
|
||||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||||
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');
|
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');
|
||||||
|
|||||||
Reference in New Issue
Block a user