Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped

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

View File

@@ -3,8 +3,8 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
class RoomConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
self.room_slug = self.scope["url_route"]["kwargs"]["room_slug"]
self.group_name = f"room_{self.room_slug}"
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
self.group_name = f"room_{self.room_id}"
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
@@ -16,3 +16,12 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
async def gate_update(self, event):
await self.send_json(event)
async def role_select_start(self, event):
await self.send_json(event)
async def turn_changed(self, event):
await self.send_json(event)
async def roles_revealed(self, event):
await self.send_json(event)

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0 on 2026-03-17 00:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0005_gateslot_debited_token_fields'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='room',
name='table_status',
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
),
migrations.CreateModel(
name='TableSeat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slot_number', models.IntegerField()),
('role', models.CharField(blank=True, choices=[('PC', 'Player'), ('BC', 'Builder'), ('SC', 'Shepherd'), ('AC', 'Alchemist'), ('NC', 'Narrator'), ('EC', 'Economist')], max_length=2, null=True)),
('role_revealed', models.BooleanField(default=False)),
('seat_position', models.IntegerField(blank=True, null=True)),
('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='table_seats', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_seats', to='epic.room')),
],
),
]

View File

@@ -29,6 +29,15 @@ class Room(models.Model):
(INVITE_ONLY, "Invite Only"),
]
ROLE_SELECT = "ROLE_SELECT"
SIG_SELECT = "SIG_SELECT"
IN_GAME = "IN_GAME"
TABLE_STATUS_CHOICES = [
(ROLE_SELECT, "Role Select"),
(SIG_SELECT, "Significator Select"),
(IN_GAME, "In Game"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
owner = models.ForeignKey(
@@ -36,6 +45,9 @@ class Room(models.Model):
)
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
table_status = models.CharField(
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
)
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
created_at = models.DateTimeField(auto_now_add=True)
board_state = models.JSONField(default=dict)
@@ -133,3 +145,31 @@ def debit_token(user, slot, token):
if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
room.gate_status = Room.OPEN
room.save()
class TableSeat(models.Model):
PC = "PC"
BC = "BC"
SC = "SC"
AC = "AC"
NC = "NC"
EC = "EC"
ROLE_CHOICES = [
(PC, "Player"),
(BC, "Builder"),
(SC, "Shepherd"),
(AC, "Alchemist"),
(NC, "Narrator"),
(EC, "Economist"),
]
PARTNER_MAP = {PC: BC, BC: PC, SC: AC, AC: SC, NC: EC, EC: NC}
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="table_seats")
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="table_seats"
)
slot_number = models.IntegerField()
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
role_revealed = models.BooleanField(default=False)
seat_position = models.IntegerField(null=True, blank=True)

View File

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

View File

@@ -0,0 +1,12 @@
(function () {
window.addEventListener('room:gate_update', function () {
const wrapper = document.getElementById('id_gate_wrapper');
if (!wrapper) return;
fetch(wrapper.dataset.gateStatusUrl)
.then(function (r) { return r.text(); })
.then(function (html) {
wrapper.outerHTML = html;
});
});
}());

View File

@@ -0,0 +1,163 @@
var RoleSelect = (function () {
var ROLES = [
{ code: "PC", name: "Player", element: "Fire" },
{ code: "BC", name: "Builder", element: "Stone" },
{ code: "SC", name: "Shepherd", element: "Air" },
{ code: "AC", name: "Alchemist", element: "Water" },
{ code: "NC", name: "Narrator", element: "Time" },
{ code: "EC", name: "Economist", element: "Space" },
];
function getSelectRoleUrl() {
var el = document.querySelector("[data-select-role-url]");
return el ? el.dataset.selectRoleUrl : null;
}
function getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : "";
}
function closeFan() {
var backdrop = document.querySelector(".role-select-backdrop");
if (backdrop) backdrop.remove();
}
function selectRole(roleCode, cardEl) {
var invCard = cardEl.cloneNode(true);
invCard.classList.add("flipped");
// strip old event listeners from the clone by replacing with a clean copy
var clean = invCard.cloneNode(true);
closeFan();
var invSlot = document.getElementById("id_inv_role_card");
if (invSlot) invSlot.appendChild(clean);
// Update the stack's taken-roles so the next openFan() filters correctly
var stack = document.querySelector(".card-stack[data-taken-roles]");
if (stack) {
var current = stack.dataset.takenRoles;
stack.dataset.takenRoles = current ? current + "," + roleCode : roleCode;
}
var url = getSelectRoleUrl();
if (!url) return;
fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCsrf(),
},
body: "role=" + encodeURIComponent(roleCode),
});
}
function getTakenRoles() {
var stack = document.querySelector(".card-stack[data-taken-roles]");
if (!stack) return [];
var raw = stack.dataset.takenRoles;
return raw ? raw.split(",").map(function (s) { return s.trim(); }) : [];
}
function openFan() {
if (document.querySelector(".role-select-backdrop")) return;
var taken = getTakenRoles();
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
var backdrop = document.createElement("div");
backdrop.className = "role-select-backdrop";
var modal = document.createElement("div");
modal.id = "id_role_select";
available.forEach(function (role) {
var card = document.createElement("div");
card.className = "card";
card.dataset.role = role.code;
var back = document.createElement("div");
back.className = "card-back";
back.textContent = "?";
var front = document.createElement("div");
front.className = "card-front";
front.innerHTML = '<div class="card-role-name">' + role.name + "</div>";
card.appendChild(back);
card.appendChild(front);
card.addEventListener("mouseenter", function () {
card.classList.add("flipped");
});
card.addEventListener("mouseleave", function () {
card.classList.remove("flipped");
});
card.addEventListener("click", function (e) {
e.stopPropagation();
selectRole(role.code, card);
});
modal.appendChild(card);
});
backdrop.appendChild(modal);
backdrop.addEventListener("click", closeFan);
document.body.appendChild(backdrop);
}
function init() {
var stack = document.querySelector(".card-stack[data-state='eligible']");
if (!stack) return;
stack.addEventListener("click", openFan);
}
var _reload = function () { window.location.reload(); };
function handleRolesRevealed() {
_reload();
}
function handleTurnChanged(event) {
var active = String(event.detail.active_slot);
// Update card-stack eligibility
var stack = document.querySelector(".card-stack[data-user-slots]");
if (stack) {
var userSlots = stack.dataset.userSlots
? stack.dataset.userSlots.split(",") : [];
if (userSlots.indexOf(active) !== -1) {
stack.dataset.state = "eligible";
stack.removeEventListener("click", openFan);
stack.addEventListener("click", openFan);
} else {
stack.dataset.state = "ineligible";
stack.removeEventListener("click", openFan);
}
}
// Move .active to the newly active seat
document.querySelectorAll(".table-seat.active").forEach(function (s) {
s.classList.remove("active");
});
var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']");
if (activeSeat) activeSeat.classList.add("active");
}
window.addEventListener("room:role_select_start", init);
window.addEventListener("room:turn_changed", handleTurnChanged);
window.addEventListener("room:roles_revealed", handleRolesRevealed);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
return {
openFan: openFan,
closeFan: closeFan,
setReload: function (fn) { _reload = fn; },
};
}());

View File

@@ -0,0 +1,17 @@
(function () {
const roomPage = document.querySelector('.room-page');
if (!roomPage) return;
const roomId = roomPage.dataset.roomId;
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
window.dispatchEvent(new CustomEvent('room:' + data.type, { detail: data }));
};
ws.onclose = function () {
console.warn('Room WebSocket closed');
};
}());

View File

@@ -15,18 +15,66 @@ TEST_CHANNEL_LAYERS = {
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomConsumerTest(SimpleTestCase):
async def test_can_connect_and_disconnect(self):
communicator = WebsocketCommunicator(application, "/ws/room/test-room/")
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.disconnect()
async def test_receives_gate_update_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/test-room/")
async def test_receives_role_select_start_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_test-room",
"room_00000000-0000-0000-0000-000000000001",
{"type": "role_select_start", "slot_order": [1, 2, 3, 4, 5, 6]},
)
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "role_select_start")
self.assertEqual(response["slot_order"], [1, 2, 3, 4, 5, 6])
await communicator.disconnect()
async def test_receives_turn_changed_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_00000000-0000-0000-0000-000000000001",
{"type": "turn_changed", "active_slot": 2},
)
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "turn_changed")
self.assertEqual(response["active_slot"], 2)
await communicator.disconnect()
async def test_receives_roles_revealed_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_00000000-0000-0000-0000-000000000001",
{"type": "roles_revealed", "assignments": {"1": "PC", "2": "BC"}},
)
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "roles_revealed")
self.assertIn("assignments", response)
await communicator.disconnect()
async def test_receives_gate_update_broadcast(self):
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
await communicator.connect()
channel_layer = get_channel_layer()
await channel_layer.group_send(
"room_00000000-0000-0000-0000-000000000001",
{"type": "gate_update", "gate_state": "some_state"},
)

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
class RoomCreationTest(TestCase):
@@ -132,6 +132,62 @@ class SelectTokenTest(TestCase):
self.assertEqual(token.token_type, Token.PASS)
class RoomTableStatusTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_table_status_defaults_to_blank(self):
self.room.refresh_from_db()
self.assertFalse(self.room.table_status)
def test_room_has_role_select_constant(self):
self.assertEqual(Room.ROLE_SELECT, "ROLE_SELECT")
def test_room_has_sig_select_constant(self):
self.assertEqual(Room.SIG_SELECT, "SIG_SELECT")
def test_room_has_in_game_constant(self):
self.assertEqual(Room.IN_GAME, "IN_GAME")
def test_table_status_accepts_role_select(self):
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.room.refresh_from_db()
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
class TableSeatModelTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.owner)
def test_table_seat_can_be_created(self):
seat = TableSeat.objects.create(
room=self.room,
gamer=self.owner,
slot_number=1,
)
self.assertEqual(seat.slot_number, 1)
self.assertIsNone(seat.role)
self.assertFalse(seat.role_revealed)
self.assertIsNone(seat.seat_position)
def test_table_seat_role_choices_cover_all_six(self):
role_codes = [c[0] for c in TableSeat.ROLE_CHOICES]
for code in ["PC", "BC", "SC", "AC", "NC", "EC"]:
self.assertIn(code, role_codes)
def test_partner_map_pairs_are_mutual(self):
for a, b in [(TableSeat.PC, TableSeat.BC), (TableSeat.SC, TableSeat.AC), (TableSeat.NC, TableSeat.EC)]:
self.assertEqual(TableSeat.PARTNER_MAP[a], b)
self.assertEqual(TableSeat.PARTNER_MAP[b], a)
def test_room_table_seats_reverse_relation(self):
TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1)
self.assertEqual(self.room.table_seats.count(), 1)
class RoomInviteTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@example.com")

View File

@@ -1,10 +1,12 @@
from datetime import timedelta
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
class RoomCreationViewTest(TestCase):
@@ -346,6 +348,298 @@ class ConfirmTokenPriorityViewTest(TestCase):
self.assertIsNone(self.coin.current_room)
class RoleSelectRenderingTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.founder)
self.gamers = [self.founder]
for i in range(2, 7):
self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
for i, gamer in enumerate(self.gamers, start=1):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
self.room.gate_status = Room.OPEN
self.room.table_status = Room.ROLE_SELECT
self.room.save()
for i, gamer in enumerate(self.gamers, start=1):
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
def test_room_view_includes_card_stack_when_role_select(self):
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "card-stack")
def test_card_stack_eligible_for_slot1_gamer(self):
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, 'data-state="eligible"')
def test_card_stack_ineligible_for_slot2_gamer(self):
self.client.force_login(self.gamers[1])
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, 'data-state="ineligible"')
def test_card_stack_ineligible_shows_fa_ban(self):
self.client.force_login(self.gamers[1])
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "fa-ban")
def test_card_stack_eligible_omits_fa_ban(self):
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertNotContains(response, "fa-ban")
def test_gatekeeper_overlay_absent_when_role_select(self):
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertNotContains(response, "gate-overlay")
def test_six_table_seats_rendered(self):
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "table-seat", count=6)
def test_active_table_seat_has_active_class(self):
self.client.force_login(self.founder) # slot 1 is active
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, 'class="table-seat active"')
def test_inactive_table_seat_lacks_active_class(self):
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
# Slots 26 are not active, so at least one plain table-seat exists
self.assertContains(response, 'class="table-seat"')
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
self.client.force_login(self.founder) # founder is slot 1 only
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, 'data-user-slots="1"')
def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
self.client.force_login(self.gamers[1]) # slot 2 gamer
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, 'data-user-slots="2"')
class PickRolesViewTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")
self.client.force_login(self.founder)
self.room = Room.objects.create(name="Test Room", owner=self.founder)
for i in range(1, 7):
gamer = self.founder if i == 1 else User.objects.create(email=f"g{i}@test.io")
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
self.room.gate_status = Room.OPEN
self.room.save()
def test_pick_roles_transitions_room_to_role_select(self):
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
self.room.refresh_from_db()
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
def test_pick_roles_creates_one_table_seat_per_filled_slot(self):
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)
def test_pick_roles_table_seats_carry_gamer_and_slot_number(self):
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
seat = TableSeat.objects.get(room=self.room, slot_number=1)
self.assertEqual(seat.gamer, self.founder)
def test_only_open_room_can_start_role_select(self):
self.room.gate_status = Room.GATHERING
self.room.save()
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
self.room.refresh_from_db()
self.assertIsNone(self.room.table_status)
def test_pick_roles_requires_login(self):
self.client.logout()
response = self.client.post(
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
)
self.assertEqual(response.status_code, 302)
self.assertIn("/accounts/login/", response.url)
def test_pick_roles_redirects_to_room(self):
response = self.client.post(
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
def test_pick_roles_notifies_channel_layer(self):
with patch("apps.epic.views._notify_role_select_start") as mock_notify:
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
mock_notify.assert_called_once_with(self.room.id)
class SelectRoleViewTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.founder)
self.gamers = [self.founder]
for i in range(2, 7):
self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
for i, gamer in enumerate(self.gamers, start=1):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
self.room.gate_status = Room.OPEN
self.room.table_status = Room.ROLE_SELECT
self.room.save()
for i, gamer in enumerate(self.gamers, start=1):
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
self.client.force_login(self.founder)
def test_select_role_records_choice(self):
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
seat = TableSeat.objects.get(room=self.room, slot_number=1)
self.assertEqual(seat.role, "PC")
def test_select_role_wrong_turn_makes_no_change(self):
self.client.force_login(self.gamers[1]) # slot 2 — not their turn
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "BC"},
)
seat = TableSeat.objects.get(room=self.room, slot_number=2)
self.assertIsNone(seat.role)
def test_turn_advances_after_selection(self):
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
next_active = TableSeat.objects.filter(
room=self.room, role__isnull=True
).order_by("slot_number").first()
self.assertEqual(next_active.slot_number, 2)
def test_all_selected_sets_sig_select(self):
roles = ["PC", "BC", "SC", "AC", "NC"]
for i, role in enumerate(roles):
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
seat.role = role
seat.save()
self.client.force_login(self.gamers[5]) # slot 6 — last
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "EC"},
)
self.room.refresh_from_db()
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
def test_select_role_notifies_turn_changed(self):
with patch("apps.epic.views._notify_turn_changed") as mock_notify:
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
mock_notify.assert_called_once_with(self.room.id)
def test_select_role_notifies_roles_revealed_when_last(self):
roles = ["PC", "BC", "SC", "AC", "NC"]
for i, role in enumerate(roles):
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
seat.role = role
seat.save()
self.client.force_login(self.gamers[5])
with patch("apps.epic.views._notify_roles_revealed") as mock_notify:
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "EC"},
)
mock_notify.assert_called_once_with(self.room.id)
def test_select_role_requires_login(self):
self.client.logout()
response = self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
self.assertEqual(response.status_code, 302)
self.assertIn("/accounts/login/", response.url)
def test_select_role_redirects_to_room(self):
response = self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
class RevealPhaseRenderingTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.founder)
gamers = [self.founder]
for i in range(2, 7):
gamers.append(User.objects.create(email=f"g{i}@test.io"))
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
TableSeat.objects.create(
room=self.room, gamer=gamer, slot_number=i,
role=role, role_revealed=True,
)
self.room.gate_status = Room.OPEN
self.room.table_status = Room.SIG_SELECT
self.room.save()
self.client.force_login(self.founder)
def test_face_up_role_cards_rendered_when_sig_select(self):
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "face-up")
def test_inv_role_card_slot_present(self):
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "id_inv_role_card")
def test_partner_indicator_present_when_sig_select(self):
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
)
self.assertContains(response, "partner-indicator")
class RoomActionsViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")

View File

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

View File

@@ -1,17 +1,60 @@
from datetime import timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
from apps.lyric.models import Token
RESERVE_TIMEOUT = timedelta(seconds=60)
def _notify_gate_update(room_id):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'gate_update'},
)
def _notify_turn_changed(room_id):
active_seat = TableSeat.objects.filter(
room_id=room_id, role__isnull=True
).order_by("slot_number").first()
active_slot = active_seat.slot_number if active_seat else None
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'turn_changed', 'active_slot': active_slot},
)
def _notify_roles_revealed(room_id):
assignments = {
str(seat.slot_number): seat.role
for seat in TableSeat.objects.filter(room_id=room_id).order_by("slot_number")
}
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'roles_revealed', 'assignments': assignments},
)
def _notify_role_select_start(room_id):
slot_order = list(
GateSlot.objects.filter(room_id=room_id, status=GateSlot.FILLED)
.order_by("slot_number")
.values_list("slot_number", flat=True)
)
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'role_select_start', 'slot_order': slot_order},
)
def _expire_reserved_slots(room):
cutoff = timezone.now() - RESERVE_TIMEOUT
room.gate_slots.filter(
@@ -79,6 +122,65 @@ def _gate_context(room, user):
}
def _role_select_context(room, user):
user_seat = None
active_seat = None
unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
if unassigned.exists():
# Normal path — TableSeats present
active_seat = unassigned.first()
user_seat = None
if user.is_authenticated:
user_seat = room.table_seats.filter(gamer=user, role__isnull=True).order_by("slot_number").first()
if user_seat and user_seat.slot_number == active_seat.slot_number:
card_stack_state = "eligible"
else:
card_stack_state = "ineligible"
else:
# Fallback — no TableSeats yet; use GateSlot drop order
active_slot = room.gate_slots.filter(
status=GateSlot.FILLED
).order_by("slot_number").first()
if active_slot is None:
card_stack_state = None
elif user.is_authenticated and active_slot.gamer == user:
card_stack_state = "eligible"
else:
card_stack_state = "ineligible"
taken_roles = list(
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
)
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
assigned_seats = (
sorted(
room.table_seats.filter(gamer=user, role__isnull=False),
key=lambda s: _action_order.get(s.role, 99),
)
if user.is_authenticated else []
)
active_slot = active_seat.slot_number if active_seat else None
ctx = {
"card_stack_state": card_stack_state,
"taken_roles": taken_roles,
"assigned_seats": assigned_seats,
"user_seat": user_seat,
"user_slots": list(
room.table_seats.filter(gamer=user, role__isnull=True)
.order_by("slot_number")
.values_list("slot_number", flat=True)
) if user.is_authenticated else [],
"active_slot": active_slot,
}
if room.table_status == Room.SIG_SELECT:
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
ctx["user_seat"] = user_seat
ctx["partner_seat"] = partner_seat
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
return ctx
@login_required
def create_room(request):
if request.method == "POST":
@@ -91,7 +193,10 @@ def create_room(request):
def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id)
ctx = _gate_context(room, request.user)
if room.table_status:
ctx = _role_select_context(room, request.user)
else:
ctx = _gate_context(room, request.user)
ctx["room"] = room
return render(request, "apps/gameboard/room.html", ctx)
@@ -113,6 +218,7 @@ def drop_token(request, room_id):
token.current_room = room
token.save()
request.session["kit_token_id"] = str(token.id)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
@@ -127,6 +233,7 @@ def drop_token(request, room_id):
slot.reserved_at = timezone.now()
slot.save()
request.session["kit_token_id"] = str(token.id)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@@ -150,6 +257,7 @@ def confirm_token(request, room_id):
if int(slot_number) > carte.slots_claimed:
carte.slots_claimed = int(slot_number)
carte.save()
_notify_gate_update(room_id)
else:
slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.RESERVED
@@ -163,6 +271,7 @@ def confirm_token(request, room_id):
token = select_token(request.user)
if token:
debit_token(request.user, slot, token)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@@ -185,6 +294,7 @@ def return_token(request, room_id):
carte.slots_claimed = 0
carte.save()
request.session.pop("kit_token_id", None)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
gamer=request.user,
@@ -214,6 +324,7 @@ def return_token(request, room_id):
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@@ -240,6 +351,52 @@ def release_slot(request, room_id):
if room.gate_status == Room.OPEN:
room.gate_status = Room.GATHERING
room.save()
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def select_role(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.table_status != Room.ROLE_SELECT:
return redirect("epic:gatekeeper", room_id=room_id)
active_seat = room.table_seats.filter(
role__isnull=True
).order_by("slot_number").first()
if not active_seat or active_seat.gamer != request.user:
return redirect("epic:gatekeeper", room_id=room_id)
role = request.POST.get("role")
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
if not role or role not in valid_roles:
return redirect("epic:gatekeeper", room_id=room_id)
if room.table_seats.filter(role=role).exists():
return redirect("epic:gatekeeper", room_id=room_id)
active_seat.role = role
active_seat.save()
if room.table_seats.filter(role__isnull=True).exists():
_notify_turn_changed(room_id)
else:
room.table_status = Room.SIG_SELECT
room.save()
_notify_roles_revealed(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def pick_roles(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN:
room.table_status = Room.ROLE_SELECT
room.save()
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
TableSeat.objects.create(
room=room,
gamer=slot.gamer,
slot_number=slot.slot_number,
)
_notify_role_select_start(room_id)
return redirect("epic:gatekeeper", room_id=room_id)