Compare commits

...

3 Commits

Author SHA1 Message Date
Disco DeDisco
736b59b5c0 role-select UX: tray timing delays, seat/circle state polish, 394 ITs green
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- _animationPending set before fetch (not in .then()) — blocks WS turn advance during in-flight request
- _placeCardDelay (3s) + _postTrayDelay (3s) give gamer time to see each step; both zeroed by _testReset()
- .role-confirmed class: full-opacity chair after placeCard completes; server-rendered on reload
- Slot circles disappear in join order (slot 1 first) via count-based logic, not role-label matching
- data-active-slot on card-stack; handleTurnChanged writes it for selectRole() to read
- #id_tray_wrap not rendered during gate phase ({% if room.table_status %})
- Tray slide/arc-in slowed to 1s for diagnostics; wobble kept at 0.45s
- Obsolete test_roles_revealed_simultaneously FT removed; T8 tray FT uses ROLE_SELECT room
- Jasmine macrotask flush pattern: await new Promise(r => setTimeout(r, 0)) after fetch .then()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 00:01:04 -04:00
Disco DeDisco
a8592aeaec hex position indicators: chair icons at hex edge midpoints replace gate-slot circles
- Split .gate-overlay into .gate-backdrop (z-100, blur) + .gate-overlay modal (z-120) so .table-position elements (z-110) render above backdrop but below modal
- New _table_positions.html partial: 6 .table-position divs with .fa-chair, role label, and .fa-ban/.fa-circle-check status icons; included unconditionally in room.html
- New epic:room view at /gameboard/room/<uuid>/; gatekeeper redirects there when table_status set; pick_roles redirects there
- role-select.js: adds .active glow to position on selectRole(); swaps .fa-ban→.fa-circle-check in placeCard onComplete; handleTurnChanged clears stale .active from all positions
- FTs: PositionIndicatorsTest (5 tests) + RoleSelectTest 8a/8b (glow + check state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:31:05 -04:00
Disco DeDisco
8b006be138 demo'd old inventory area in room.html to make way for new content (hex table now centered in view); old test suite now targets Role card in #id_tray cells where appropriate, or skips Sig card select until aforementioned new feature deployed; new scripts & jasmine tests too; removed one irrelevant test case from apps.epic.tests.ITs.test_views.SelectRoleViewTest 2026-03-30 16:42:23 -04:00
19 changed files with 1575 additions and 628 deletions

View File

@@ -3,6 +3,20 @@ var RoleSelect = (function () {
// ahead of the fetch response doesn't get overridden by Tray.open(). // ahead of the fetch response doesn't get overridden by Tray.open().
var _turnChangedBeforeFetch = false; var _turnChangedBeforeFetch = false;
// Set to true while placeCard animation is running. handleTurnChanged
// defers its work until the animation completes.
var _animationPending = false;
var _pendingTurnChange = null;
// Delay before the tray animation begins (ms). Gives the gamer a moment
// to see their pick confirmed before the tray slides in. Set to 0 by
// _testReset() so Jasmine tests don't need jasmine.clock().
var _placeCardDelay = 3000;
// Delay after the tray closes before advancing to the next turn (ms).
// Gives the gamer a moment to see their confirmed seat before the turn moves.
var _postTrayDelay = 3000;
var ROLES = [ var ROLES = [
{ code: "PC", name: "Player", element: "Fire" }, { code: "PC", name: "Player", element: "Fire" },
{ code: "BC", name: "Builder", element: "Stone" }, { code: "BC", name: "Builder", element: "Stone" },
@@ -27,17 +41,13 @@ var RoleSelect = (function () {
if (backdrop) backdrop.remove(); if (backdrop) backdrop.remove();
} }
function selectRole(roleCode, cardEl) { function selectRole(roleCode) {
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag _turnChangedBeforeFetch = false; // fresh selection, reset the race flag
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(); closeFan();
var invSlot = document.getElementById("id_inv_role_card"); // Show the tray handle — gamer confirmed a pick, tray animation about to run
if (invSlot) invSlot.appendChild(clean); var trayWrap = document.getElementById("id_tray_wrap");
if (trayWrap) trayWrap.classList.remove("role-select-phase");
// Immediately lock the stack — do not wait for WS turn_changed // Immediately lock the stack — do not wait for WS turn_changed
var stack = document.querySelector(".card-stack[data-starter-roles]"); var stack = document.querySelector(".card-stack[data-starter-roles]");
@@ -48,8 +58,24 @@ var RoleSelect = (function () {
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode; stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
} }
// Mark seat as actively being claimed (glow state)
var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
if (activePos) activePos.classList.add('active');
// Immediately fade out the gate-slot circle for the current turn's slot
var activeSlot = stack ? stack.dataset.activeSlot : null;
if (activeSlot) {
var slotCircle = document.querySelector('.gate-slot[data-slot="' + activeSlot + '"]');
if (slotCircle) slotCircle.classList.add('role-assigned');
}
var url = getSelectRoleUrl(); var url = getSelectRoleUrl();
if (!url) return; if (!url) return;
// Block handleTurnChanged immediately — WS turn_changed can arrive while
// the fetch is in-flight and must be deferred until our animation completes.
_animationPending = true;
fetch(url, { fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
@@ -60,24 +86,41 @@ var RoleSelect = (function () {
}).then(function (response) { }).then(function (response) {
if (!response.ok) { if (!response.ok) {
// Server rejected (role already taken) — undo optimistic update // Server rejected (role already taken) — undo optimistic update
if (invSlot && invSlot.contains(clean)) invSlot.removeChild(clean); _animationPending = false;
if (stack) { if (stack) {
stack.dataset.starterRoles = stack.dataset.starterRoles stack.dataset.starterRoles = stack.dataset.starterRoles
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(","); .split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
} }
openFan(); openFan();
} else { } else {
// Place role card in tray grid and open the tray // Animate the role card into the tray: open, arc-in, force-close.
var grid = document.getElementById("id_tray_grid"); // Any turn_changed that arrived while the fetch was in-flight is
if (grid) { // queued in _pendingTurnChange and will run after onComplete.
var trayCard = document.createElement("div"); if (typeof Tray !== "undefined") {
trayCard.className = "tray-cell tray-role-card"; setTimeout(function () {
trayCard.dataset.role = roleCode; Tray.placeCard(roleCode, function () {
grid.insertBefore(trayCard, grid.firstChild); // Swap ban → check, clear glow, mark seat as confirmed
var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
if (seatedPos) {
seatedPos.classList.remove('active');
seatedPos.classList.add('role-confirmed');
var ban = seatedPos.querySelector('.fa-ban');
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
} }
// Only open if turn_changed hasn't already arrived and closed it. // Hold _animationPending through the post-tray pause so any
if (typeof Tray !== "undefined" && !_turnChangedBeforeFetch) { // turn_changed WS event that arrives now is still deferred.
Tray.open(); setTimeout(function () {
_animationPending = false;
if (_pendingTurnChange) {
var ev = _pendingTurnChange;
_pendingTurnChange = null;
handleTurnChanged(ev);
}
}, _postTrayDelay);
});
}, _placeCardDelay);
} else {
_animationPending = false;
} }
} }
}); });
@@ -135,7 +178,7 @@ var RoleSelect = (function () {
"Start round 1 as<br>" + role.name + " (" + role.code + ") …?", "Start round 1 as<br>" + role.name + " (" + role.code + ") …?",
function () { // confirm function () { // confirm
card.classList.remove("guard-active"); card.classList.remove("guard-active");
selectRole(role.code, card); selectRole(role.code);
}, },
function () { // dismiss (NVM / outside click) function () { // dismiss (NVM / outside click)
card.classList.remove("guard-active"); card.classList.remove("guard-active");
@@ -165,15 +208,52 @@ var RoleSelect = (function () {
} }
function handleTurnChanged(event) { function handleTurnChanged(event) {
// If a placeCard animation is running, defer until it completes.
if (_animationPending) {
_pendingTurnChange = event;
return;
}
var active = String(event.detail.active_slot); var active = String(event.detail.active_slot);
var invSlot = document.getElementById("id_inv_role_card");
if (invSlot) invSlot.innerHTML = "";
// Force-close tray instantly so it never obscures the next player's card-stack. // Force-close tray instantly so it never obscures the next player's card-stack.
// Also set the race flag so the fetch .then() doesn't re-open if it arrives late. // Also set the race flag so the fetch .then() doesn't re-open if it arrives late.
_turnChangedBeforeFetch = true; _turnChangedBeforeFetch = true;
if (typeof Tray !== "undefined") Tray.forceClose(); if (typeof Tray !== "undefined") Tray.forceClose();
// Hide tray handle until the next player confirms their pick
var trayWrap = document.getElementById("id_tray_wrap");
if (trayWrap) trayWrap.classList.add("role-select-phase");
// Clear any stale .active glow from hex seats
document.querySelectorAll('.table-seat.active').forEach(function (p) {
p.classList.remove('active');
});
// Sync seat icons from starter_roles so state persists without a reload
if (event.detail.starter_roles) {
var assignedRoles = event.detail.starter_roles;
document.querySelectorAll(".table-seat").forEach(function (seat) {
var role = seat.dataset.role;
if (assignedRoles.indexOf(role) !== -1) {
seat.classList.add("role-confirmed");
var ban = seat.querySelector(".fa-ban");
if (ban) { ban.classList.remove("fa-ban"); ban.classList.add("fa-circle-check"); }
}
});
// Hide slot circles in turn order: slots 1..N done when N roles assigned
var assignedCount = assignedRoles.length;
document.querySelectorAll(".gate-slot[data-slot]").forEach(function (circle) {
if (parseInt(circle.dataset.slot, 10) <= assignedCount) {
circle.classList.add("role-assigned");
}
});
}
// Update active slot on the card stack so selectRole() can read it
var stack = document.querySelector(".card-stack[data-user-slots]");
if (stack) stack.dataset.activeSlot = active;
var stack = document.querySelector(".card-stack[data-user-slots]"); var stack = document.querySelector(".card-stack[data-user-slots]");
if (stack) { if (stack) {
// Sync starter-roles from server so the fan reflects actual DB state // Sync starter-roles from server so the fan reflects actual DB state
@@ -201,12 +281,10 @@ var RoleSelect = (function () {
} }
} }
// Move .active to the newly active seat // Clear any stale seat glow (JS-only; glow is only during tray animation)
document.querySelectorAll(".table-seat.active").forEach(function (s) { document.querySelectorAll(".table-seat.active").forEach(function (s) {
s.classList.remove("active"); 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:role_select_start", init);
@@ -223,5 +301,12 @@ var RoleSelect = (function () {
openFan: openFan, openFan: openFan,
closeFan: closeFan, closeFan: closeFan,
setReload: function (fn) { _reload = fn; }, setReload: function (fn) { _reload = fn; },
// Testing hook — resets animation-pause state between Jasmine specs
_testReset: function () {
_animationPending = false;
_pendingTurnChange = null;
_placeCardDelay = 0;
_postTrayDelay = 0;
},
}; };
}()); }());

View File

@@ -224,6 +224,37 @@ var Tray = (function () {
}); });
} }
// _arcIn — add .arc-in to cardEl, wait for animationend, remove it, call onComplete.
function _arcIn(cardEl, onComplete) {
cardEl.classList.add('arc-in');
cardEl.addEventListener('animationend', function handler() {
cardEl.removeEventListener('animationend', handler);
cardEl.classList.remove('arc-in');
if (onComplete) onComplete();
});
}
// placeCard(roleCode, onComplete) — mark the first tray cell with the role,
// open the tray, arc-in the cell, then force-close. Calls onComplete after.
// The grid always contains exactly 8 .tray-cell elements (from the template);
// the first one receives .tray-role-card and data-role instead of a new element
// being inserted, so the cell count never changes.
function placeCard(roleCode, onComplete) {
if (!_grid) { if (onComplete) onComplete(); return; }
var firstCell = _grid.querySelector('.tray-cell');
if (!firstCell) { if (onComplete) onComplete(); return; }
firstCell.classList.add('tray-role-card');
firstCell.dataset.role = roleCode;
firstCell.textContent = roleCode;
open();
_arcIn(firstCell, function () {
forceClose();
if (onComplete) onComplete();
});
}
function _startDrag(clientX, clientY) { function _startDrag(clientX, clientY) {
_dragHandled = false; _dragHandled = false;
if (_wrap) _wrap.classList.add('tray-dragging'); if (_wrap) _wrap.classList.add('tray-dragging');
@@ -428,6 +459,14 @@ var Tray = (function () {
_onBtnClick = null; _onBtnClick = null;
} }
_cancelPendingHide(); _cancelPendingHide();
// Clear any role-card state from tray cells (Jasmine afterEach)
if (_grid) {
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
el.classList.remove('tray-role-card', 'arc-in');
el.textContent = '';
delete el.dataset.role;
});
}
_wrap = null; _wrap = null;
_btn = null; _btn = null;
_tray = null; _tray = null;
@@ -446,6 +485,7 @@ var Tray = (function () {
close: close, close: close,
forceClose: forceClose, forceClose: forceClose,
isOpen: isOpen, isOpen: isOpen,
placeCard: placeCard,
reset: reset, reset: reset,
_testSetLandscape: function (v) { _landscapeOverride = v; }, _testSetLandscape: function (v) { _landscapeOverride = v; },
}; };

View File

@@ -367,85 +367,172 @@ class RoleSelectRenderingTest(TestCase):
self.room.save() self.room.save()
for i, gamer in enumerate(self.gamers, start=1): for i, gamer in enumerate(self.gamers, start=1):
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i) TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
def test_room_view_includes_card_stack_when_role_select(self): def test_room_view_includes_card_stack_when_role_select(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertContains(response, "card-stack") self.assertContains(response, "card-stack")
def test_card_stack_eligible_for_slot1_gamer(self): def test_card_stack_eligible_for_slot1_gamer(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertContains(response, 'data-state="eligible"') self.assertContains(response, 'data-state="eligible"')
def test_card_stack_ineligible_for_slot2_gamer(self): def test_card_stack_ineligible_for_slot2_gamer(self):
self.client.force_login(self.gamers[1]) self.client.force_login(self.gamers[1])
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertContains(response, 'data-state="ineligible"') self.assertContains(response, 'data-state="ineligible"')
def test_card_stack_ineligible_shows_fa_ban(self): def test_card_stack_ineligible_shows_fa_ban(self):
self.client.force_login(self.gamers[1]) self.client.force_login(self.gamers[1])
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertContains(response, "fa-ban") self.assertContains(response, "fa-ban")
def test_card_stack_eligible_omits_fa_ban(self): def test_card_stack_eligible_omits_fa_ban(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertNotContains(response, "fa-ban") # Seat ban icons carry "position-status-icon"; card-stack ban does not.
# Assert the bare "fa-solid fa-ban" (card-stack form) is absent.
self.assertNotContains(response, 'class="fa-solid fa-ban"')
def test_gatekeeper_overlay_absent_when_role_select(self): def test_gatekeeper_overlay_absent_when_role_select(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertNotContains(response, "gate-overlay") self.assertNotContains(response, "gate-overlay")
def test_tray_wrap_has_role_select_phase_class(self):
# Tray handle hidden until gamer confirms a role pick
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')
def test_tray_absent_during_gatekeeper_phase(self):
# Tray must not render before the gamer occupies a seat
room = Room.objects.create(name="Gate Room", owner=self.founder)
self.client.force_login(self.founder)
response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": room.id})
)
self.assertNotContains(response, 'id="id_tray_wrap"')
def test_six_table_seats_rendered(self): def test_six_table_seats_rendered(self):
self.client.force_login(self.founder) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertContains(response, "table-seat", count=6) self.assertContains(response, "table-seat", count=6)
def test_active_table_seat_has_active_class(self): def test_table_seats_never_active_on_load(self):
self.client.force_login(self.founder) # slot 1 is active # Seat glow is JS-only (during tray animation); never server-rendered
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) self.client.force_login(self.founder)
response = self.client.get( response = self.client.get(self.url)
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.assertNotContains(response, 'class="table-seat active"')
)
# Slots 26 are not active, so at least one plain table-seat exists def test_assigned_seat_renders_role_confirmed_class(self):
self.assertContains(response, 'class="table-seat"') # A seat with a role already picked must load as role-confirmed (opaque chair)
self.gamers[0].refresh_from_db()
seat = self.room.table_seats.get(slot_number=1)
seat.role = "PC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, 'table-seat role-confirmed')
def test_unassigned_seat_lacks_role_confirmed_class(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertNotContains(response, 'table-seat role-confirmed')
def test_assigned_slot_circle_renders_role_assigned_class(self):
# Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based)
seat = self.room.table_seats.get(slot_number=1)
seat.role = "PC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, 'gate-slot filled role-assigned')
def test_slot_circle_hides_by_count_not_role_label(self):
# Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's
seat = self.room.table_seats.get(slot_number=1)
seat.role = "NC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
content = response.content.decode()
import re
# Template renders class before data-slot; capture both orderings
circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content)
slot1_classes = next((cls for cls, slot in circles if slot == "1"), "")
slot2_classes = next((cls for cls, slot in circles if slot == "2"), "")
self.assertIn("role-assigned", slot1_classes)
self.assertNotIn("role-assigned", slot2_classes)
def test_unassigned_slot_circle_lacks_role_assigned_class(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertNotContains(response, 'role-assigned')
def test_position_strip_rendered_during_role_select(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, "position-strip")
def test_position_strip_has_six_gate_slots(self):
self.client.force_login(self.founder)
response = self.client.get(self.url)
self.assertContains(response, "gate-slot", count=6)
def test_card_stack_has_data_user_slots_for_eligible_gamer(self): def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
self.client.force_login(self.founder) # founder is slot 1 only self.client.force_login(self.founder) # founder is slot 1 only
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertContains(response, 'data-user-slots="1"') self.assertContains(response, 'data-user-slots="1"')
def test_card_stack_has_data_user_slots_for_ineligible_gamer(self): def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
self.client.force_login(self.gamers[1]) # slot 2 gamer self.client.force_login(self.gamers[1]) # slot 2 gamer
response = self.client.get( response = self.client.get(
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url
) )
self.assertContains(response, 'data-user-slots="2"') self.assertContains(response, 'data-user-slots="2"')
def test_assigned_seat_renders_check_icon(self):
seat = self.room.table_seats.get(slot_number=1)
seat.role = "PC"
seat.save()
self.client.force_login(self.founder)
response = self.client.get(self.url)
content = response.content.decode()
# The PC seat should have fa-circle-check, not fa-ban
pc_seat_start = content.index('data-role="PC"')
pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300]
self.assertIn("fa-circle-check", pc_seat_chunk)
self.assertNotIn("fa-ban", pc_seat_chunk)
def test_unassigned_seat_renders_ban_icon(self):
# slot 2's role is still null
self.client.force_login(self.founder)
response = self.client.get(self.url)
content = response.content.decode()
nc_seat_start = content.index('data-role="NC"')
nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300]
self.assertIn("fa-ban", nc_seat_chunk)
self.assertNotIn("fa-circle-check", nc_seat_chunk)
class PickRolesViewTest(TestCase): class PickRolesViewTest(TestCase):
def setUp(self): def setUp(self):
@@ -495,7 +582,7 @@ class PickRolesViewTest(TestCase):
reverse("epic:pick_roles", kwargs={"room_id": self.room.id}) reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
) )
self.assertRedirects( self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id]) response, reverse("epic:room", args=[self.room.id])
) )
def test_pick_roles_notifies_channel_layer(self): def test_pick_roles_notifies_channel_layer(self):
@@ -633,7 +720,7 @@ class SelectRoleViewTest(TestCase):
data={"role": "BOGUS"}, data={"role": "BOGUS"},
) )
self.assertRedirects( self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id]) response, reverse("epic:room", args=[self.room.id])
) )
def test_same_gamer_cannot_double_pick_sequentially(self): def test_same_gamer_cannot_double_pick_sequentially(self):
@@ -648,50 +735,13 @@ class SelectRoleViewTest(TestCase):
data={"role": "BC"}, data={"role": "BC"},
) )
self.assertRedirects( self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id]) response, reverse("epic:room", args=[self.room.id])
) )
self.assertEqual( self.assertEqual(
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1 TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
) )
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")
@@ -815,7 +865,7 @@ class SigSelectRenderingTest(TestCase):
def setUp(self): def setUp(self):
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
self.url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
def test_sig_deck_element_present(self): def test_sig_deck_element_present(self):
response = self.client.get(self.url) response = self.client.get(self.url)
@@ -915,7 +965,7 @@ class SelectSigCardViewTest(TestCase):
self.room.save() self.room.save()
response = self._post() response = self._post()
self.assertRedirects( self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id]) response, reverse("epic:room", args=[self.room.id])
) )
def test_select_sig_last_choice_does_not_advance_to_none(self): def test_select_sig_last_choice_does_not_advance_to_none(self):

View File

@@ -6,6 +6,7 @@ app_name = 'epic'
urlpatterns = [ urlpatterns = [
path('rooms/create_room', views.create_room, name='create_room'), path('rooms/create_room', views.create_room, name='create_room'),
path('room/<uuid:room_id>/', views.room_view, name='room'),
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'), path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'), path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
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'),

View File

@@ -71,6 +71,24 @@ def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
) )
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
def _gate_positions(room):
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
# of which role each gamer chose — so use count, not role matching.
assigned_count = room.table_seats.exclude(role__isnull=True).count()
return [
{
"slot": slot,
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
"role_assigned": slot.slot_number <= assigned_count,
}
for slot in room.gate_slots.order_by("slot_number")
]
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(
@@ -135,6 +153,8 @@ def _gate_context(room, user):
"carte_slots_claimed": carte_slots_claimed, "carte_slots_claimed": carte_slots_claimed,
"carte_nvm_slot_number": carte_nvm_slot_number, "carte_nvm_slot_number": carte_nvm_slot_number,
"carte_next_slot_number": carte_next_slot_number, "carte_next_slot_number": carte_next_slot_number,
"gate_positions": _gate_positions(room),
"starter_roles": [],
} }
@@ -186,6 +206,8 @@ def _role_select_context(room, user):
.values_list("slot_number", flat=True) .values_list("slot_number", flat=True)
) if user.is_authenticated else [], ) if user.is_authenticated else [],
"active_slot": active_slot, "active_slot": active_slot,
"gate_positions": _gate_positions(room),
"slots": room.gate_slots.order_by("slot_number"),
} }
if room.table_status == Room.SIG_SELECT: if room.table_status == Room.SIG_SELECT:
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
@@ -215,14 +237,21 @@ 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)
if room.table_status: if room.table_status:
ctx = _role_select_context(room, request.user) return redirect("epic:room", room_id=room_id)
else:
ctx = _gate_context(room, request.user) ctx = _gate_context(room, request.user)
ctx["room"] = room ctx["room"] = room
ctx["page_class"] = "page-gameboard" ctx["page_class"] = "page-gameboard"
return render(request, "apps/gameboard/room.html", ctx) return render(request, "apps/gameboard/room.html", ctx)
def room_view(request, room_id):
room = Room.objects.get(id=room_id)
ctx = _role_select_context(room, request.user)
ctx["room"] = room
ctx["page_class"] = "page-gameboard"
return render(request, "apps/gameboard/room.html", ctx)
@login_required @login_required
def drop_token(request, room_id): def drop_token(request, room_id):
if request.method == "POST": if request.method == "POST":
@@ -390,17 +419,20 @@ def select_role(request, room_id):
if request.method == "POST": if request.method == "POST":
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
if room.table_status != Room.ROLE_SELECT: if room.table_status != Room.ROLE_SELECT:
return redirect("epic:gatekeeper", room_id=room_id) return redirect(
"epic:room" if room.table_status else "epic:gatekeeper",
room_id=room_id,
)
role = request.POST.get("role") role = request.POST.get("role")
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES] valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
if not role or role not in valid_roles: if not role or role not in valid_roles:
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:room", room_id=room_id)
with transaction.atomic(): with transaction.atomic():
active_seat = room.table_seats.select_for_update().filter( active_seat = room.table_seats.select_for_update().filter(
role__isnull=True role__isnull=True
).order_by("slot_number").first() ).order_by("slot_number").first()
if not active_seat or active_seat.gamer != request.user: if not active_seat or active_seat.gamer != request.user:
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:room", room_id=room_id)
if room.table_seats.filter(role=role).exists(): if room.table_seats.filter(role=role).exists():
return HttpResponse(status=409) return HttpResponse(status=409)
active_seat.role = role active_seat.role = role
@@ -416,7 +448,7 @@ def select_role(request, room_id):
record(room, GameEvent.ROLES_REVEALED) record(room, GameEvent.ROLES_REVEALED)
_notify_roles_revealed(room_id) _notify_roles_revealed(room_id)
return HttpResponse(status=200) return HttpResponse(status=200)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:room", room_id=room_id)
@login_required @login_required
@@ -433,7 +465,7 @@ def pick_roles(request, room_id):
slot_number=slot.slot_number, slot_number=slot.slot_number,
) )
_notify_role_select_start(room_id) _notify_role_select_start(room_id)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:room", room_id=room_id)
@login_required @login_required
@@ -487,7 +519,10 @@ def select_sig(request, room_id):
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
if room.table_status != Room.SIG_SELECT: if room.table_status != Room.SIG_SELECT:
return redirect("epic:gatekeeper", room_id=room_id) return redirect(
"epic:room" if room.table_status else "epic:gatekeeper",
room_id=room_id,
)
active_seat = active_sig_seat(room) active_seat = active_sig_seat(room)
if active_seat is None or active_seat.gamer != request.user: if active_seat is None or active_seat.gamer != request.user:
return HttpResponse(status=403) return HttpResponse(status=403)

View File

@@ -8,6 +8,7 @@ from .base import FunctionalTest
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.epic.models import Room, GateSlot, select_token from apps.epic.models import Room, GateSlot, select_token
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from .test_room_role_select import _fill_room_via_orm
class GatekeeperTest(FunctionalTest): class GatekeeperTest(FunctionalTest):
@@ -585,3 +586,117 @@ class GameKitInsertTest(FunctionalTest):
) )
self.assertTrue(Token.objects.filter(id=pass_token.id).exists()) self.assertTrue(Token.objects.filter(id=pass_token.id).exists())
self.assertEqual(self.browser.current_url, self.gate_url) self.assertEqual(self.browser.current_url, self.gate_url)
class PositionIndicatorsTest(FunctionalTest):
"""Hex-side position indicators — always rendered outside the gatekeeper modal."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
self.create_pre_authenticated_session("founder@test.io")
self.founder, _ = User.objects.get_or_create(email="founder@test.io")
self.room = Room.objects.create(name="Position Test Room", owner=self.founder)
self.gate_url = (
f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
)
# ------------------------------------------------------------------ #
# Test P1 — 6 position circles present in strip alongside gatekeeper #
# ------------------------------------------------------------------ #
def test_position_indicators_visible_alongside_gatekeeper(self):
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
)
# Six .gate-slot elements are rendered in .position-strip, outside modal
strip = self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
slots = strip.find_elements(By.CSS_SELECTOR, ".gate-slot")
self.assertEqual(len(slots), 6)
for slot in slots:
self.assertTrue(slot.is_displayed())
# ------------------------------------------------------------------ #
# Test P2 — URL drops /gate/ after pick_roles #
# ------------------------------------------------------------------ #
def test_url_drops_gate_after_pick_roles(self):
_fill_room_via_orm(self.room, [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.browser.get(self.gate_url)
expected_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/"
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, expected_url)
)
# ------------------------------------------------------------------ #
# Test P3 — Gate-slot circles live outside the modal #
# ------------------------------------------------------------------ #
def test_position_circles_outside_gatekeeper_modal(self):
"""The numbered position circles must NOT be descendants of .gate-modal —
they live in .position-strip which sits above the backdrop."""
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal")
)
# No .gate-slot inside the modal
modal_slots = self.browser.find_elements(
By.CSS_SELECTOR, ".gate-modal .gate-slot"
)
self.assertEqual(len(modal_slots), 0)
# All 6 live in .position-strip
strip_slots = self.browser.find_elements(
By.CSS_SELECTOR, ".position-strip .gate-slot"
)
self.assertEqual(len(strip_slots), 6)
# ------------------------------------------------------------------ #
# Test P4 — Each circle displays its slot number #
# ------------------------------------------------------------------ #
def test_position_circle_shows_slot_number(self):
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
)
for n in range(1, 7):
slot_el = self.browser.find_element(
By.CSS_SELECTOR, f".position-strip .gate-slot[data-slot='{n}']"
)
self.assertIn(str(n), slot_el.text)
# ------------------------------------------------------------------ #
# Test P5 — Filled slot carries .filled class in strip #
# ------------------------------------------------------------------ #
def test_filled_slot_shown_in_strip(self):
from apps.epic.models import GateSlot
slot = self.room.gate_slots.get(slot_number=1)
slot.gamer = self.founder
slot.status = GateSlot.FILLED
slot.save()
self.browser.get(self.gate_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
)
slot1 = self.browser.find_element(
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='1']"
)
self.assertIn("filled", slot1.get_attribute("class"))
slot2 = self.browser.find_element(
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='2']"
)
self.assertIn("empty", slot2.get_attribute("class"))

View File

@@ -216,14 +216,7 @@ class RoleSelectTest(FunctionalTest):
) )
) )
# 7. Role card appears in inventory # 7. Card stack returns to table centre
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( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack") lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
) )
@@ -323,46 +316,6 @@ class RoleSelectTest(FunctionalTest):
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card") cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
self.assertEqual(len(cards), 5) 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 # # Test 4 — Click-away dismisses fan without selecting #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -392,17 +345,13 @@ class RoleSelectTest(FunctionalTest):
# Click the backdrop (outside the fan) # Click the backdrop (outside the fan)
self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click() self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click()
# Modal closes; stack still present; inventory still empty # Modal closes; stack still present
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertEqual(
len(self.browser.find_elements(By.ID, "id_role_select")), 0 len(self.browser.find_elements(By.ID, "id_role_select")), 0
) )
) )
self.browser.find_element(By.CSS_SELECTOR, ".card-stack") 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
)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -496,12 +445,14 @@ class RoleSelectTest(FunctionalTest):
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Test 7All roles revealed simultaneously after all gamers select # # Test 8aHex seats carry role labels during role select #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_roles_revealed_simultaneously_after_all_select(self): def test_seats_around_hex_have_role_labels(self):
"""During role select the 6 .table-seat elements carry data-role
attributes matching the fixed slot→role mapping (PC at slot 1, etc.)."""
founder, _ = User.objects.get_or_create(email="founder@test.io") founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Reveal Test", owner=founder) room = Room.objects.create(name="Seat Label Test", owner=founder)
_fill_room_via_orm(room, [ _fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io", "founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
@@ -513,33 +464,103 @@ class RoleSelectTest(FunctionalTest):
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url) self.browser.get(room_url)
# Assign all roles via ORM (simulating all gamers having chosen) expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
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( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".partner-indicator") lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
)
for slot_number, role_label in expected.items():
seat = self.browser.find_element(
By.CSS_SELECTOR, f".table-seat[data-slot='{slot_number}']"
)
self.assertEqual(seat.get_attribute("data-role"), role_label)
# ------------------------------------------------------------------ #
# Test 8b — Hex seats show .fa-ban when empty #
# ------------------------------------------------------------------ #
def test_seats_show_ban_icon_when_empty(self):
"""All 6 seats carry .fa-ban before any role has been chosen."""
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Seat Ban 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()
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/"
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
)
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
self.assertEqual(len(seats), 6)
for seat in seats:
self.assertTrue(
seat.find_elements(By.CSS_SELECTOR, ".fa-ban"),
f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}",
)
# ------------------------------------------------------------------ #
# Test 8c — Hex seat gets .fa-circle-check after role selected #
# ------------------------------------------------------------------ #
def test_seat_gets_check_after_role_selected(self):
"""After confirming a role pick the corresponding hex seat should
show .fa-circle-check and lose .fa-ban."""
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Seat Check 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()
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/"
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(room_url)
# Open fan, pick first card (PC), confirm guard
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
)
).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()
self.confirm_guard()
# Wait for tray animation to complete
self.wait_for(
lambda: self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"),
"Tray should close after arc-in sequence",
)
)
# The PC seat (slot 1) now shows check, no ban
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-circle-check"
)
)
self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-ban"
)),
0,
) )
@@ -590,12 +611,13 @@ class RoleSelectTrayTest(FunctionalTest):
self.confirm_guard() self.confirm_guard()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# T1 — Portrait: role card at topmost grid square, tray opens # # T1 — Portrait: role card marks first cell; tray opens then closes #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_portrait_role_card_enters_topmost_grid_square(self): def test_portrait_role_card_enters_topmost_grid_square(self):
"""Portrait: after confirming a role, a .tray-role-card is the first child """Portrait: after confirming a role the first .tray-cell gets
of #id_tray_grid (topmost cell) and the tray is open.""" .tray-role-card; the grid still has exactly 8 cells; and the tray
opens briefly then closes once the arc-in animation completes."""
self.browser.set_window_size(390, 844) self.browser.set_window_size(390, 844)
room = self._make_room() room = self._make_room()
self.create_pre_authenticated_session("slot1@test.io") self.create_pre_authenticated_session("slot1@test.io")
@@ -604,34 +626,42 @@ class RoleSelectTrayTest(FunctionalTest):
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
self._select_role() self._select_role()
# Card appears in the grid. # First cell receives the role card class.
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card" By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
) )
) )
# It is the first child — topmost in portrait. result = self.browser.execute_script("""
is_first = self.browser.execute_script(""" var grid = document.getElementById('id_tray_grid');
var card = document.querySelector('#id_tray_grid .tray-role-card'); var card = grid.querySelector('.tray-role-card');
return card !== null && card === card.parentElement.firstElementChild; return {
isFirst: card !== null && card === grid.firstElementChild,
count: grid.children.length,
role: card ? card.dataset.role : null
};
""") """)
self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid") self.assertTrue(result["isFirst"], "Role card should be the first cell")
self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
self.assertTrue(result["role"], "First cell should carry data-role")
# Tray is open. # Tray closes after the animation sequence.
self.assertTrue( self.wait_for(
lambda: self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"), self.browser.execute_script("return Tray.isOpen()"),
"Tray should be open after role selection" "Tray should close after the arc-in sequence"
)
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# T2 — Landscape: role card at leftmost grid square, tray opens # # T2 — Landscape: same contract in landscape #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@tag('two-browser') @tag('two-browser')
def test_landscape_role_card_enters_leftmost_grid_square(self): def test_landscape_role_card_enters_leftmost_grid_square(self):
"""Landscape: after confirming a role, a .tray-role-card is the first child """Landscape: the first .tray-cell gets .tray-role-card; grid has
of #id_tray_grid (leftmost cell) and the tray is open.""" 8 cells; tray opens then closes."""
self.browser.set_window_size(844, 390) self.browser.set_window_size(844, 390)
room = self._make_room() room = self._make_room()
self.create_pre_authenticated_session("slot1@test.io") self.create_pre_authenticated_session("slot1@test.io")
@@ -640,24 +670,28 @@ class RoleSelectTrayTest(FunctionalTest):
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap")) self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
self._select_role() self._select_role()
# Card appears in the grid.
self.wait_for( self.wait_for(
lambda: self.browser.find_element( lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card" By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
) )
) )
# It is the first child — leftmost in landscape. result = self.browser.execute_script("""
is_first = self.browser.execute_script(""" var grid = document.getElementById('id_tray_grid');
var card = document.querySelector('#id_tray_grid .tray-role-card'); var card = grid.querySelector('.tray-role-card');
return card !== null && card === card.parentElement.firstElementChild; return {
isFirst: card !== null && card === grid.firstElementChild,
count: grid.children.length
};
""") """)
self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid") self.assertTrue(result["isFirst"], "Role card should be the first cell")
self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
# Tray is open. self.wait_for(
self.assertTrue( lambda: self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"), self.browser.execute_script("return Tray.isOpen()"),
"Tray should be open after role selection" "Tray should close after the arc-in sequence"
)
) )
@@ -693,11 +727,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
) )
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
# 1. Watcher loads the room — slot 1 is active on initial render # 1. Watcher (slot 2) loads the room
self.create_pre_authenticated_session("watcher@test.io") self.create_pre_authenticated_session("watcher@test.io")
self.browser.get(room_url) self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" By.CSS_SELECTOR, ".card-stack[data-state='ineligible']"
)) ))
# 2. Founder picks a role in second browser # 2. Founder picks a role in second browser
@@ -712,16 +746,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
self.confirm_guard(browser=self.browser2) self.confirm_guard(browser=self.browser2)
# 3. Watcher's seat arc moves to slot 2 — no page refresh # 3. Watcher's turn arrives via WS — card-stack becomes eligible
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
)) ))
self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']"
)),
0,
)
finally: finally:
self.browser2.quit() self.browser2.quit()
@@ -784,16 +812,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
return card !== null && card === card.parentElement.firstElementChild; return card !== null && card === card.parentElement.firstElementChild;
"""))) """)))
# Turn advances via WS — seat 2 becomes active. # Turn advances via WS — tray must close (forceClose in handleTurnChanged).
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.assertFalse(
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
))
# Tray must be closed: forceClose() fires in handleTurnChanged.
self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"), self.browser.execute_script("return Tray.isOpen()"),
"Tray should be closed after turn advances" "Tray should be closed after turn advances"
) ))
def test_landscape_tray_closes_on_turn_advance(self): def test_landscape_tray_closes_on_turn_advance(self):
"""Landscape: role card at leftmost grid square; tray closes when """Landscape: role card at leftmost grid square; tray closes when
@@ -817,15 +840,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
return card !== null && card === card.parentElement.firstElementChild; return card !== null && card === card.parentElement.firstElementChild;
"""))) """)))
# Turn advances via WS — seat 2 becomes active. # Turn advances via WS — tray must close (forceClose in handleTurnChanged).
self.wait_for(lambda: self.browser.find_element( self.wait_for(lambda: self.assertFalse(
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
))
# Tray must be closed.
self.assertFalse(
self.browser.execute_script("return Tray.isOpen()"), self.browser.execute_script("return Tray.isOpen()"),
"Tray should be closed after turn advances" "Tray should be closed after turn advances"
) ))

View File

@@ -189,12 +189,9 @@ class SigSelectTest(FunctionalTest):
) )
) )
# Founder's significator appears in their inventory # TODO: sig card should appear in the tray (tray.placeCard for sig phase)
self.wait_for( # once sig-select.js is updated to call Tray.placeCard instead of
lambda: self.browser.find_element( # appending to the removed #id_inv_sig_card inventory element.
By.CSS_SELECTOR, "#id_inv_sig_card .card"
)
)
# Active seat advances to NC # Active seat advances to NC
self.wait_for( self.wait_for(

View File

@@ -80,6 +80,20 @@ class TrayTest(FunctionalTest):
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true})); document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
""", btn, start_y, end_y) """, btn, start_y, end_y)
def _make_role_select_room(self, founder_email="founder@test.io"):
from apps.epic.models import TableSeat
founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder)
emails = [founder_email, "nc@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"]
_fill_room_via_orm(room, emails)
room.table_status = Room.ROLE_SELECT
room.save()
for i, email in enumerate(emails, start=1):
gamer, _ = User.objects.get_or_create(email=email)
TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i)
return room
def _make_sig_select_room(self, founder_email="founder@test.io"): def _make_sig_select_room(self, founder_email="founder@test.io"):
founder, _ = User.objects.get_or_create(email=founder_email) founder, _ = User.objects.get_or_create(email=founder_email)
room = Room.objects.create(name="Tray Test Room", owner=founder) room = Room.objects.create(name="Tray Test Room", owner=founder)
@@ -262,7 +276,7 @@ class TrayTest(FunctionalTest):
@tag('two-browser') @tag('two-browser')
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self): def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
room = self._make_sig_select_room() room = self._make_role_select_room()
self.create_pre_authenticated_session("founder@test.io") self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room)) self.browser.get(self._room_url(room))

View File

@@ -2,12 +2,12 @@ describe("RoleSelect", () => {
let testDiv; let testDiv;
beforeEach(() => { beforeEach(() => {
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
testDiv = document.createElement("div"); testDiv = document.createElement("div");
testDiv.innerHTML = ` testDiv.innerHTML = `
<div class="room-page" <div class="room-page"
data-select-role-url="/epic/room/test-uuid/select-role"> data-select-role-url="/epic/room/test-uuid/select-role">
</div> </div>
<div id="id_inv_role_card"></div>
`; `;
document.body.appendChild(testDiv); document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue( window.fetch = jasmine.createSpy("fetch").and.returnValue(
@@ -102,12 +102,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull(); 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", () => { it("clicking a card POSTs to the select_role URL", () => {
const card = document.querySelector("#id_role_select .card"); const card = document.querySelector("#id_role_select .card");
card.click(); card.click();
@@ -117,11 +111,6 @@ describe("RoleSelect", () => {
); );
}); });
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);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -135,11 +124,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull(); 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();
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -169,7 +153,7 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("room:turn_changed event", () => { describe("room:turn_changed event", () => {
let stack; let stack, trayWrap;
beforeEach(() => { beforeEach(() => {
// Six table seats, slot 1 starts active // Six table seats, slot 1 starts active
@@ -186,6 +170,11 @@ describe("RoleSelect", () => {
stack.dataset.userSlots = "1"; stack.dataset.userSlots = "1";
stack.dataset.starterRoles = ""; stack.dataset.starterRoles = "";
testDiv.appendChild(stack); testDiv.appendChild(stack);
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
}); });
it("calls Tray.forceClose() on turn change", () => { it("calls Tray.forceClose() on turn change", () => {
@@ -196,13 +185,19 @@ describe("RoleSelect", () => {
expect(Tray.forceClose).toHaveBeenCalled(); expect(Tray.forceClose).toHaveBeenCalled();
}); });
it("moves .active to the newly active seat", () => { it("clears .active from all seats on turn change", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", { window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 } detail: { active_slot: 2 }
})); }));
expect( expect(testDiv.querySelector(".table-seat.active")).toBeNull();
testDiv.querySelector(".table-seat.active").dataset.slot });
).toBe("2");
it("re-adds role-select-phase to tray wrap on turn change", () => {
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 }
}));
expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
}); });
it("removes .active from the previously active seat", () => { it("removes .active from the previously active seat", () => {
@@ -248,6 +243,119 @@ describe("RoleSelect", () => {
stack.click(); stack.click();
expect(document.querySelector(".role-select-backdrop")).toBeNull(); expect(document.querySelector(".role-select-backdrop")).toBeNull();
}); });
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).toBeNull();
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
});
it("adds role-confirmed to seat when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "NC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).not.toBeNull();
expect(seat.querySelector(".fa-circle-check")).toBeNull();
});
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(true);
});
it("leaves slot-2 circle visible when only 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "2";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(false);
});
it("updates data-active-slot on card stack to the new active slot", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(stack.dataset.activeSlot).toBe("2");
});
});
// ------------------------------------------------------------------ //
// selectRole slot-circle fade-out //
// ------------------------------------------------------------------ //
describe("selectRole() slot-circle behaviour", () => {
let circle, stack;
beforeEach(() => {
// Gate-slot circle for slot 1 (active turn)
circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
// Card stack with active-slot=1 so selectRole() knows which circle to hide
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
testDiv.appendChild(stack);
spyOn(Tray, "placeCard");
});
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
expect(circle.classList.contains("role-assigned")).toBe(true);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -259,20 +367,18 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => { describe("tray card after successful role selection", () => {
let grid, guardConfirm; let guardConfirm, trayWrap;
beforeEach(() => { beforeEach(() => {
// Minimal tray grid matching room.html structure trayWrap = document.createElement("div");
grid = document.createElement("div"); trayWrap.id = "id_tray_wrap";
grid.id = "id_tray_grid"; trayWrap.className = "role-select-phase";
for (let i = 0; i < 8; i++) { testDiv.appendChild(trayWrap);
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
testDiv.appendChild(grid);
spyOn(Tray, "open"); // Spy on Tray.placeCard: call the onComplete callback immediately.
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
if (cb) cb();
});
// Capturing guard spy — holds onConfirm so we can fire it per-test // Capturing guard spy — holds onConfirm so we can fire it per-test
window.showGuard = jasmine.createSpy("showGuard").and.callFake( window.showGuard = jasmine.createSpy("showGuard").and.callFake(
@@ -283,54 +389,154 @@ describe("RoleSelect", () => {
document.querySelector("#id_role_select .card").click(); document.querySelector("#id_role_select .card").click();
}); });
it("prepends a .tray-role-card to #id_tray_grid on success", async () => { it("calls Tray.placeCard() on success", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
expect(grid.querySelector(".tray-role-card")).not.toBeNull(); await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
expect(Tray.placeCard).toHaveBeenCalled();
}); });
it("tray-role-card is the first child of #id_tray_grid", async () => { it("passes the role code string to Tray.placeCard", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true); await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
expect(typeof roleCode).toBe("string");
expect(roleCode.length).toBeGreaterThan(0);
}); });
it("tray-role-card carries the selected role as data-role", async () => { it("does not call Tray.placeCard() on server rejection", async () => {
guardConfirm();
await Promise.resolve();
const trayCard = grid.querySelector(".tray-role-card");
expect(trayCard.dataset.role).toBeTruthy();
});
it("calls Tray.open() on success", async () => {
guardConfirm();
await Promise.resolve();
expect(Tray.open).toHaveBeenCalled();
});
it("does not prepend a tray-role-card on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue( window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false }) Promise.resolve({ ok: false })
); );
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve();
expect(grid.querySelector(".tray-role-card")).toBeNull(); expect(Tray.placeCard).not.toHaveBeenCalled();
}); });
it("does not call Tray.open() on server rejection", async () => { it("removes role-select-phase from tray wrap on successful pick", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue( guardConfirm();
Promise.resolve({ ok: false }) await Promise.resolve();
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
});
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
// Add a seat element matching the first available role (PC)
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
testDiv.appendChild(seat);
guardConfirm();
await Promise.resolve(); // fetch resolves + placeCard fires
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
});
// ------------------------------------------------------------------ //
// WS turn_changed pause during animation //
// ------------------------------------------------------------------ //
describe("WS turn_changed pause during placeCard animation", () => {
let stack, guardConfirm;
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);
testDiv.appendChild(seat);
}
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "ineligible";
stack.dataset.userSlots = "1";
stack.dataset.starterRoles = "";
testDiv.appendChild(stack);
const grid = document.createElement("div");
grid.id = "id_tray_grid";
testDiv.appendChild(grid);
// placeCard spy that holds the onComplete callback
let heldCallback = null;
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
heldCallback = cb; // don't call immediately — simulate animation in-flight
});
spyOn(Tray, "forceClose");
// Expose heldCallback so tests can fire it
Tray._testFirePlaceCardComplete = () => {
if (heldCallback) heldCallback();
};
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
); );
guardConfirm();
await Promise.resolve();
expect(Tray.open).not.toHaveBeenCalled();
}); });
it("grid grows by exactly 1 on success", async () => { afterEach(() => {
const before = grid.children.length; delete Tray._testFirePlaceCardComplete;
RoleSelect._testReset();
});
it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve(); // fetch resolves; placeCard called; animation pending
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).not.toHaveBeenCalled();
});
it("turn_changed during animation does not immediately move the active seat", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve();
expect(grid.children.length).toBe(before + 1);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
});
it("deferred turn_changed is processed when animation completes", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
// Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
Tray._testFirePlaceCardComplete();
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
// Seat glow is JS-only (tray animation window); after deferred
// handleTurnChanged runs, all seat glows are cleared.
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
});
it("turn_changed after animation completes is processed immediately", () => {
// No animation in flight — turn_changed should run right away
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).toHaveBeenCalled();
// Seats are not persistently glowed; all active cleared
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
}); });
@@ -433,9 +639,6 @@ describe("RoleSelect", () => {
); );
}); });
it("appends a .card to #id_inv_role_card", () => {
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
});
}); });
describe("dismissing the guard (NVM or outside click)", () => { describe("dismissing the guard (NVM or outside click)", () => {
@@ -463,10 +666,6 @@ describe("RoleSelect", () => {
expect(window.fetch).not.toHaveBeenCalled(); expect(window.fetch).not.toHaveBeenCalled();
}); });
it("does not add a card to inventory", () => {
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
});
it("restores normal mouseleave behaviour on the card", () => { it("restores normal mouseleave behaviour on the card", () => {
card.dispatchEvent(new MouseEvent("mouseenter")); card.dispatchEvent(new MouseEvent("mouseenter"));
card.dispatchEvent(new MouseEvent("mouseleave")); card.dispatchEvent(new MouseEvent("mouseleave"));

View File

@@ -422,4 +422,97 @@ describe("Tray", () => {
expect(Tray.isOpen()).toBe(false); expect(Tray.isOpen()).toBe(false);
}); });
}); });
// ---------------------------------------------------------------------- //
// placeCard() //
// ---------------------------------------------------------------------- //
//
// placeCard(roleCode, onComplete):
// 1. Marks the first .tray-cell with .tray-role-card + data-role.
// 2. Opens the tray.
// 3. Arc-in animates the cell (.arc-in class, animationend fires).
// 4. forceClose() — tray closes instantly.
// 5. Calls onComplete.
//
// The grid always has exactly 8 .tray-cell elements (from the template);
// no new elements are inserted.
//
// ---------------------------------------------------------------------- //
describe("placeCard()", () => {
let grid, firstCell;
beforeEach(() => {
grid = document.createElement("div");
grid.id = "id_tray_grid";
for (let i = 0; i < 8; i++) {
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
document.body.appendChild(grid);
// Re-init so _grid is set (reset() in outer afterEach clears it)
Tray.init();
firstCell = grid.querySelector(".tray-cell");
});
afterEach(() => {
grid.remove();
});
it("adds .tray-role-card to the first .tray-cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
});
it("sets data-role on the first cell", () => {
Tray.placeCard("NC", null);
expect(firstCell.dataset.role).toBe("NC");
});
it("grid cell count stays at 8", () => {
Tray.placeCard("PC", null);
expect(grid.children.length).toBe(8);
});
it("opens the tray", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
});
it("adds .arc-in to the first cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("arc-in")).toBe(true);
});
it("removes .arc-in and force-closes after animationend", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
firstCell.dispatchEvent(new Event("animationend"));
expect(firstCell.classList.contains("arc-in")).toBe(false);
expect(Tray.isOpen()).toBe(false);
});
it("calls onComplete after the tray closes", () => {
let called = false;
Tray.placeCard("PC", () => { called = true; });
firstCell.dispatchEvent(new Event("animationend"));
expect(called).toBe(true);
});
it("landscape: same behaviour — first cell gets role card", () => {
Tray._testSetLandscape(true);
Tray.init();
Tray.placeCard("EC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
expect(firstCell.dataset.role).toBe("EC");
});
it("reset() removes .tray-role-card and data-role from cells", () => {
Tray.placeCard("PC", null);
Tray.reset();
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
expect(firstCell.dataset.role).toBeUndefined();
});
});
}); });

View File

@@ -40,7 +40,7 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
border: none; border: none;
border-top: 0.1rem solid rgba(var(--terUser), 0.3); border-top: 0.1rem solid rgba(var(--quaUser), 1);
background: rgba(var(--priUser), 0.97); background: rgba(var(--priUser), 0.97);
z-index: 316; z-index: 316;
overflow: hidden; overflow: hidden;
@@ -81,7 +81,7 @@
text-transform: uppercase; text-transform: uppercase;
text-decoration: underline; text-decoration: underline;
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: rgba(var(--secUser), 0.35); color: rgba(var(--quaUser), 0.75);
writing-mode: vertical-rl; writing-mode: vertical-rl;
text-orientation: mixed; text-orientation: mixed;
transform: rotate(180deg); transform: rotate(180deg);
@@ -117,8 +117,8 @@
.kit-bag-placeholder { .kit-bag-placeholder {
font-size: 1.5rem; font-size: 1.5rem;
opacity: 0.3;
padding: 0 0.125rem; padding: 0 0.125rem;
color: rgba(var(--quaUser), 0.3);
} }
} }

View File

@@ -36,25 +36,29 @@ $gate-line: 2px;
// disrupt pointer events on position:fixed descendants. // disrupt pointer events on position:fixed descendants.
// NOTE: may be superfluous — root cause of CI kit-btn failures turned out to be // NOTE: may be superfluous — root cause of CI kit-btn failures turned out to be
// game-kit.js missing from git (was in gitignored STATIC_ROOT only). // game-kit.js missing from git (was in gitignored STATIC_ROOT only).
html:has(.gate-overlay) { html:has(.gate-backdrop) {
overflow: hidden; overflow: hidden;
} }
.gate-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 100;
pointer-events: none;
}
.gate-overlay { .gate-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.7); z-index: 120;
backdrop-filter: blur(4px);
z-index: 100;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
// Prevents backdrop from intercepting clicks on position:fixed elements
// (e.g. #id_kit_btn) in Linux headless Firefox.
// NOTE: may be superfluous — see html:has comment above.
pointer-events: none; pointer-events: none;
} }
@@ -235,68 +239,6 @@ html:has(.gate-overlay) {
} }
} }
.gate-slots {
display: flex;
flex-direction: row;
align-items: center;
gap: $gate-gap;
.gate-slot {
position: relative;
width: $gate-node;
height: $gate-node;
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.filled,
&.reserved {
background: rgba(var(--terUser), 0.2);
}
&.filled:hover,
&.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
;
}
.slot-number {
font-size: 0.7em;
opacity: 0.5;
}
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
// CARTE drop-target circle — matches .reserved appearance
&:has(.drop-token-btn) {
background: rgba(var(--terUser), 0.2);
&:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
;
}
}
}
}
.form-container { .form-container {
margin-top: 1rem; margin-top: 1rem;
} }
@@ -313,24 +255,6 @@ html:has(.gate-overlay) {
} }
.token-slot { min-width: 150px; } .token-slot { min-width: 150px; }
.gate-slots {
display: grid;
grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(2, 52px);
gap: 24px;
.gate-slot {
width: 52px;
height: 52px;
&:nth-child(1) { grid-column: 1; grid-row: 1; }
&:nth-child(2) { grid-column: 2; grid-row: 1; }
&:nth-child(3) { grid-column: 3; grid-row: 1; }
&:nth-child(4) { grid-column: 1; grid-row: 2; }
&:nth-child(5) { grid-column: 2; grid-row: 2; }
&:nth-child(6) { grid-column: 3; grid-row: 2; }
}
}
} }
} }
@@ -362,6 +286,108 @@ $seat-r: 130px;
$seat-r-x: round($seat-r * 0.866); // 113px $seat-r-x: round($seat-r * 0.866); // 113px
$seat-r-y: round($seat-r * 0.5); // 65px $seat-r-y: round($seat-r * 0.5); // 65px
// Seat edge-midpoint geometry (pointy-top hex).
// Apothem ≈ 80px + 30px clearance = 110px total push from centre.
$pos-d: 110px;
$pos-d-x: round($pos-d * 0.5); // 55px
$pos-d-y: round($pos-d * 0.866); // 95px
// ─── Position strip ────────────────────────────────────────────────────────
// Numbered gate-slot circles rendered above the backdrop (z 130 > overlay 120
// > backdrop 100). .room-page is position:relative with no z-index, so its
// absolute children share the root stacking context with the fixed overlays.
.position-strip {
position: absolute;
top: 0.5rem;
left: 0;
right: 0;
z-index: 130;
display: flex;
justify-content: center;
gap: round($gate-gap * 0.6);
pointer-events: none;
.gate-slot {
position: relative;
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 0.5);
background: rgba(var(--priUser), 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
pointer-events: auto;
font-size: 1.8rem;
transition: opacity 0.6s ease, transform 0.6s ease;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--priUser), 0.12)
;
&.role-assigned {
opacity: 0;
transform: scale(0.5);
pointer-events: none;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terUser), 0.12)
;
}
&.filled, &.reserved {
background: rgba(var(--terUser), 0.9);
border-color: rgba(var(--terUser), 1);
color: rgba(var(--priUser), 1);
}
&.filled:hover, &.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
.slot-number { font-size: 0.7em; opacity: 0.5; }
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
&:has(.drop-token-btn) {
background: rgba(var(--terUser), 1);
border-color: rgba(var(--ninUser), 0.5);
&:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
}
}
}
@media (max-width: 700px) {
.position-strip {
gap: round($gate-gap * 0.3);
.gate-slot {
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
}
}
}
.room-table { .room-table {
flex: 2; flex: 2;
position: relative; position: relative;
@@ -419,33 +445,63 @@ $seat-r-y: round($seat-r * 0.5); // 65px
justify-content: 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 { .table-seat {
position: absolute; position: absolute;
display: flex; display: grid;
flex-direction: column; grid-template-columns: auto auto;
grid-template-rows: auto auto;
column-gap: 0.25rem;
align-items: center; align-items: center;
gap: 0.25rem;
// Centre the element on its anchor point
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
pointer-events: none;
// Clockwise from top — slot drop order during ROLE_SELECT // Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
&[data-slot="1"] { left: 50%; top: calc(50% - #{$seat-r}); } &[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
&[data-slot="2"] { left: calc(50% + #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } &[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="3"] { left: calc(50% + #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } &[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="4"] { left: 50%; top: calc(50% + #{$seat-r}); } &[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
&[data-slot="5"] { left: calc(50% - #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } &[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
&[data-slot="6"] { left: calc(50% - #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } &[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
// Chair: col 1, spans both rows
.fa-chair {
grid-column: 1;
grid-row: 1 / 3;
font-size: 1.6rem;
color: rgba(var(--secUser), 0.4);
transition: color 0.6s ease, filter 0.6s ease;
}
// Abbreviation: col 2, row 1
.seat-role-label {
grid-column: 2;
grid-row: 1;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(var(--secUser), 1);
}
// Status icon: col 2, row 2, centred under the abbreviation
.position-status-icon {
grid-column: 2;
grid-row: 2;
justify-self: center;
font-size: 0.8rem;
&.fa-ban { color: rgba(var(--priRd), 1); }
&.fa-circle-check { color: rgba(var(--priGn), 1); }
}
&.active .fa-chair {
color: rgba(var(--terUser), 1);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
}
// After role confirmed: chair settles to full-opacity --secUser (no glow)
&.role-confirmed .fa-chair {
color: rgba(var(--secUser), 1);
filter: none;
}
.seat-portrait { .seat-portrait {
width: 36px; width: 36px;
@@ -617,56 +673,6 @@ $card-h: 120px;
} }
} }
// ─── 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: 1440px) { @media (orientation: landscape) and (max-width: 1440px) {
.gate-modal { .gate-modal {
@@ -690,17 +696,6 @@ $inv-strip: 30px; // visible height of each stacked card after the first
} }
} }
.gate-slots {
gap: 14px;
.gate-slot {
width: 40px;
height: 40px;
.slot-number { font-size: 0.6em; }
}
}
.form-container { .form-container {
margin-top: 0.75rem; margin-top: 0.75rem;
h3 { font-size: 0.85rem; margin: 0.5rem 0; } h3 { font-size: 0.85rem; margin: 0.5rem 0; }

View File

@@ -24,6 +24,10 @@ $handle-rect-h: 72px;
$handle-exposed: 48px; $handle-exposed: 48px;
$handle-r: 1rem; $handle-r: 1rem;
#id_tray_wrap.role-select-phase {
#id_tray_handle { visibility: hidden; pointer-events: none; }
}
#id_tray_wrap { #id_tray_wrap {
position: fixed; position: fixed;
// left set by JS: closed = vw - handleW; open = vw - wrapW // left set by JS: closed = vw - handleW; open = vw - wrapW
@@ -37,11 +41,11 @@ $handle-r: 1rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1); transition: left 1.0s cubic-bezier(0.4, 0, 0.2, 1);
&.tray-dragging { transition: none; } &.tray-dragging { transition: none; }
&.wobble { animation: tray-wobble 0.45s ease; } &.wobble { animation: tray-wobble .45s ease; }
&.snap { animation: tray-snap 0.30s ease; } &.snap { animation: tray-snap 1.0s ease; }
} }
#id_tray_handle { #id_tray_handle {
@@ -117,6 +121,27 @@ $handle-r: 1rem;
&::before { border-color: rgba(var(--quaUser), 1); } &::before { border-color: rgba(var(--quaUser), 1); }
} }
// ─── Role card: arc-in animation (portrait) ─────────────────────────────────
@keyframes tray-role-arc-in {
from { opacity: 0; transform: scale(0.3) translate(-40%, -40%); }
to { opacity: 1; transform: scale(1) translate(0, 0); }
}
.tray-role-card {
background: rgba(var(--quaUser), 0.25);
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0.2em;
font-size: 0.65rem;
color: rgba(var(--quaUser), 1);
font-weight: 600;
&.arc-in {
animation: tray-role-arc-in 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
}
@keyframes tray-wobble { @keyframes tray-wobble {
0%, 100% { transform: translateX(0); } 0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); } 20% { transform: translateX(-8px); }
@@ -197,11 +222,11 @@ $handle-r: 1rem;
right: $sidebar-w; right: $sidebar-w;
top: auto; // JS controls style.top for the Y-axis slide top: auto; // JS controls style.top for the Y-axis slide
bottom: auto; bottom: auto;
transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1); transition: top 1.0s cubic-bezier(0.4, 0, 0.2, 1);
&.tray-dragging { transition: none; } &.tray-dragging { transition: none; }
&.wobble { animation: tray-wobble-landscape 0.45s ease; } &.wobble { animation: tray-wobble-landscape 0.45s ease; }
&.snap { animation: tray-snap-landscape 0.30s ease; } &.snap { animation: tray-snap-landscape 1.0s ease; }
} }
@@ -276,6 +301,16 @@ $handle-r: 1rem;
border-bottom: none; border-bottom: none;
} }
// Role card arc-in for landscape
@keyframes tray-role-arc-in-landscape {
from { opacity: 0; transform: scale(0.3) translate(-40%, 40%); }
to { opacity: 1; transform: scale(1) translate(0, 0); }
}
.tray-role-card.arc-in {
animation: tray-role-arc-in-landscape 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes tray-wobble-landscape { @keyframes tray-wobble-landscape {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
20% { transform: translateY(-8px); } 20% { transform: translateY(-8px); }

View File

@@ -2,12 +2,12 @@ describe("RoleSelect", () => {
let testDiv; let testDiv;
beforeEach(() => { beforeEach(() => {
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
testDiv = document.createElement("div"); testDiv = document.createElement("div");
testDiv.innerHTML = ` testDiv.innerHTML = `
<div class="room-page" <div class="room-page"
data-select-role-url="/epic/room/test-uuid/select-role"> data-select-role-url="/epic/room/test-uuid/select-role">
</div> </div>
<div id="id_inv_role_card"></div>
`; `;
document.body.appendChild(testDiv); document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue( window.fetch = jasmine.createSpy("fetch").and.returnValue(
@@ -102,12 +102,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull(); 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", () => { it("clicking a card POSTs to the select_role URL", () => {
const card = document.querySelector("#id_role_select .card"); const card = document.querySelector("#id_role_select .card");
card.click(); card.click();
@@ -117,11 +111,6 @@ describe("RoleSelect", () => {
); );
}); });
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);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -135,11 +124,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull(); 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();
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -169,7 +153,7 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("room:turn_changed event", () => { describe("room:turn_changed event", () => {
let stack; let stack, trayWrap;
beforeEach(() => { beforeEach(() => {
// Six table seats, slot 1 starts active // Six table seats, slot 1 starts active
@@ -186,6 +170,12 @@ describe("RoleSelect", () => {
stack.dataset.userSlots = "1"; stack.dataset.userSlots = "1";
stack.dataset.starterRoles = ""; stack.dataset.starterRoles = "";
testDiv.appendChild(stack); testDiv.appendChild(stack);
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
// Simulate server-side class during ROLE_SELECT
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
}); });
it("calls Tray.forceClose() on turn change", () => { it("calls Tray.forceClose() on turn change", () => {
@@ -196,13 +186,19 @@ describe("RoleSelect", () => {
expect(Tray.forceClose).toHaveBeenCalled(); expect(Tray.forceClose).toHaveBeenCalled();
}); });
it("moves .active to the newly active seat", () => { it("re-adds role-select-phase to tray wrap on turn change", () => {
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
window.dispatchEvent(new CustomEvent("room:turn_changed", { window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 } detail: { active_slot: 2 }
})); }));
expect( expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
testDiv.querySelector(".table-seat.active").dataset.slot });
).toBe("2");
it("clears .active from all seats on turn change", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 }
}));
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
it("removes .active from the previously active seat", () => { it("removes .active from the previously active seat", () => {
@@ -248,6 +244,119 @@ describe("RoleSelect", () => {
stack.click(); stack.click();
expect(document.querySelector(".role-select-backdrop")).toBeNull(); expect(document.querySelector(".role-select-backdrop")).toBeNull();
}); });
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).toBeNull();
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
});
it("adds role-confirmed to seat when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "NC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).not.toBeNull();
expect(seat.querySelector(".fa-circle-check")).toBeNull();
});
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(true);
});
it("leaves slot-2 circle visible when only 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "2";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(false);
});
it("updates data-active-slot on card stack to the new active slot", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(stack.dataset.activeSlot).toBe("2");
});
});
// ------------------------------------------------------------------ //
// selectRole slot-circle fade-out //
// ------------------------------------------------------------------ //
describe("selectRole() slot-circle behaviour", () => {
let circle, stack;
beforeEach(() => {
// Gate-slot circle for slot 1 (active turn)
circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
// Card stack with active-slot=1 so selectRole() knows which circle to hide
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
testDiv.appendChild(stack);
spyOn(Tray, "placeCard");
});
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
expect(circle.classList.contains("role-assigned")).toBe(true);
});
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -259,20 +368,18 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => { describe("tray card after successful role selection", () => {
let grid, guardConfirm; let guardConfirm, trayWrap;
beforeEach(() => { beforeEach(() => {
// Minimal tray grid matching room.html structure trayWrap = document.createElement("div");
grid = document.createElement("div"); trayWrap.id = "id_tray_wrap";
grid.id = "id_tray_grid"; trayWrap.className = "role-select-phase";
for (let i = 0; i < 8; i++) { testDiv.appendChild(trayWrap);
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
testDiv.appendChild(grid);
spyOn(Tray, "open"); // Spy on Tray.placeCard: call the onComplete callback immediately.
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
if (cb) cb();
});
// Capturing guard spy — holds onConfirm so we can fire it per-test // Capturing guard spy — holds onConfirm so we can fire it per-test
window.showGuard = jasmine.createSpy("showGuard").and.callFake( window.showGuard = jasmine.createSpy("showGuard").and.callFake(
@@ -283,54 +390,154 @@ describe("RoleSelect", () => {
document.querySelector("#id_role_select .card").click(); document.querySelector("#id_role_select .card").click();
}); });
it("prepends a .tray-role-card to #id_tray_grid on success", async () => { it("calls Tray.placeCard() on success", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
expect(grid.querySelector(".tray-role-card")).not.toBeNull(); await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
expect(Tray.placeCard).toHaveBeenCalled();
}); });
it("tray-role-card is the first child of #id_tray_grid", async () => { it("passes the role code string to Tray.placeCard", async () => {
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve(); // flush fetch .then()
expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true); await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
expect(typeof roleCode).toBe("string");
expect(roleCode.length).toBeGreaterThan(0);
}); });
it("tray-role-card carries the selected role as data-role", async () => { it("does not call Tray.placeCard() on server rejection", async () => {
guardConfirm();
await Promise.resolve();
const trayCard = grid.querySelector(".tray-role-card");
expect(trayCard.dataset.role).toBeTruthy();
});
it("calls Tray.open() on success", async () => {
guardConfirm();
await Promise.resolve();
expect(Tray.open).toHaveBeenCalled();
});
it("does not prepend a tray-role-card on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue( window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false }) Promise.resolve({ ok: false })
); );
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve();
expect(grid.querySelector(".tray-role-card")).toBeNull(); expect(Tray.placeCard).not.toHaveBeenCalled();
}); });
it("does not call Tray.open() on server rejection", async () => { it("removes role-select-phase from tray wrap on successful pick", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue( guardConfirm();
Promise.resolve({ ok: false }) await Promise.resolve();
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
});
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
// Add a seat element matching the first available role (PC)
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
testDiv.appendChild(seat);
guardConfirm();
await Promise.resolve(); // fetch resolves + placeCard fires
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
});
// ------------------------------------------------------------------ //
// WS turn_changed pause during animation //
// ------------------------------------------------------------------ //
describe("WS turn_changed pause during placeCard animation", () => {
let stack, guardConfirm;
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);
testDiv.appendChild(seat);
}
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "ineligible";
stack.dataset.userSlots = "1";
stack.dataset.starterRoles = "";
testDiv.appendChild(stack);
const grid = document.createElement("div");
grid.id = "id_tray_grid";
testDiv.appendChild(grid);
// placeCard spy that holds the onComplete callback
let heldCallback = null;
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
heldCallback = cb; // don't call immediately — simulate animation in-flight
});
spyOn(Tray, "forceClose");
// Expose heldCallback so tests can fire it
Tray._testFirePlaceCardComplete = () => {
if (heldCallback) heldCallback();
};
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
); );
guardConfirm();
await Promise.resolve();
expect(Tray.open).not.toHaveBeenCalled();
}); });
it("grid grows by exactly 1 on success", async () => { afterEach(() => {
const before = grid.children.length; delete Tray._testFirePlaceCardComplete;
RoleSelect._testReset();
});
it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve(); // fetch resolves; placeCard called; animation pending
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).not.toHaveBeenCalled();
});
it("turn_changed during animation does not immediately move the active seat", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm(); guardConfirm();
await Promise.resolve(); await Promise.resolve();
expect(grid.children.length).toBe(before + 1);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
});
it("deferred turn_changed is processed when animation completes", async () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
// Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
Tray._testFirePlaceCardComplete();
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
// Seat glow is JS-only (tray animation window); after deferred
// handleTurnChanged runs, all seat glows are cleared.
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
});
it("turn_changed after animation completes is processed immediately", () => {
// No animation in flight — turn_changed should run right away
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).toHaveBeenCalled();
// Seats are not persistently glowed; all active cleared
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
}); });
}); });
@@ -433,9 +640,6 @@ describe("RoleSelect", () => {
); );
}); });
it("appends a .card to #id_inv_role_card", () => {
expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
});
}); });
describe("dismissing the guard (NVM or outside click)", () => { describe("dismissing the guard (NVM or outside click)", () => {
@@ -463,10 +667,6 @@ describe("RoleSelect", () => {
expect(window.fetch).not.toHaveBeenCalled(); expect(window.fetch).not.toHaveBeenCalled();
}); });
it("does not add a card to inventory", () => {
expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
});
it("restores normal mouseleave behaviour on the card", () => { it("restores normal mouseleave behaviour on the card", () => {
card.dispatchEvent(new MouseEvent("mouseenter")); card.dispatchEvent(new MouseEvent("mouseenter"));
card.dispatchEvent(new MouseEvent("mouseleave")); card.dispatchEvent(new MouseEvent("mouseleave"));

View File

@@ -422,4 +422,97 @@ describe("Tray", () => {
expect(Tray.isOpen()).toBe(false); expect(Tray.isOpen()).toBe(false);
}); });
}); });
// ---------------------------------------------------------------------- //
// placeCard() //
// ---------------------------------------------------------------------- //
//
// placeCard(roleCode, onComplete):
// 1. Marks the first .tray-cell with .tray-role-card + data-role.
// 2. Opens the tray.
// 3. Arc-in animates the cell (.arc-in class, animationend fires).
// 4. forceClose() — tray closes instantly.
// 5. Calls onComplete.
//
// The grid always has exactly 8 .tray-cell elements (from the template);
// no new elements are inserted.
//
// ---------------------------------------------------------------------- //
describe("placeCard()", () => {
let grid, firstCell;
beforeEach(() => {
grid = document.createElement("div");
grid.id = "id_tray_grid";
for (let i = 0; i < 8; i++) {
const cell = document.createElement("div");
cell.className = "tray-cell";
grid.appendChild(cell);
}
document.body.appendChild(grid);
// Re-init so _grid is set (reset() in outer afterEach clears it)
Tray.init();
firstCell = grid.querySelector(".tray-cell");
});
afterEach(() => {
grid.remove();
});
it("adds .tray-role-card to the first .tray-cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
});
it("sets data-role on the first cell", () => {
Tray.placeCard("NC", null);
expect(firstCell.dataset.role).toBe("NC");
});
it("grid cell count stays at 8", () => {
Tray.placeCard("PC", null);
expect(grid.children.length).toBe(8);
});
it("opens the tray", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
});
it("adds .arc-in to the first cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("arc-in")).toBe(true);
});
it("removes .arc-in and force-closes after animationend", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
firstCell.dispatchEvent(new Event("animationend"));
expect(firstCell.classList.contains("arc-in")).toBe(false);
expect(Tray.isOpen()).toBe(false);
});
it("calls onComplete after the tray closes", () => {
let called = false;
Tray.placeCard("PC", () => { called = true; });
firstCell.dispatchEvent(new Event("animationend"));
expect(called).toBe(true);
});
it("landscape: same behaviour — first cell gets role card", () => {
Tray._testSetLandscape(true);
Tray.init();
Tray.placeCard("EC", null);
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
expect(firstCell.dataset.role).toBe("EC");
});
it("reset() removes .tray-role-card and data-role from cells", () => {
Tray.placeCard("PC", null);
Tray.reset();
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
expect(firstCell.dataset.role).toBeUndefined();
});
});
}); });

View File

@@ -2,6 +2,7 @@
id="id_gate_wrapper" id="id_gate_wrapper"
data-gate-status-url="{% url 'epic:gate_status' room.id %}" data-gate-status-url="{% url 'epic:gate_status' room.id %}"
> >
<div class="gate-backdrop"></div>
<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">
@@ -43,43 +44,6 @@
{% endif %} {% endif %}
</div> </div>
<div class="gate-slots row">
{% for slot in slots %}
<div
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% elif slot.status == 'RESERVED' %} reserved{% endif %}"
data-slot="{{ slot.slot_number }}"
>
<span class="slot-number">{{ slot.slot_number }}</span>
{% if slot.gamer %}
<span class="slot-gamer">{{ slot.gamer.email }}</span>
{% else %}
<span class="slot-gamer">empty</span>
{% endif %}
{% if slot.status == 'RESERVED' and slot.gamer == request.user %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
{% if is_last_slot %}
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
{% else %}
<button type="submit" class="btn btn-confirm">OK</button>
{% endif %}
</form>
{% elif carte_active and slot.status == 'EMPTY' and slot.slot_number == carte_next_slot_number %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
<button type="submit" class="drop-token-btn btn btn-confirm" aria-label="Fill slot {{ slot.slot_number }}">OK</button>
</form>
{% elif carte_active and slot.status == 'FILLED' and slot.slot_number == carte_nvm_slot_number %}
<form method="POST" action="{% url 'epic:release_slot' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ slot.slot_number }}">
<button type="submit" class="slot-release-btn btn btn-cancel">NVM</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% if room.gate_status == 'OPEN' %} {% if room.gate_status == 'OPEN' %}
<form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents"> <form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents">
{% csrf_token %} {% csrf_token %}

View File

@@ -0,0 +1,31 @@
<div class="position-strip">
{% for pos in gate_positions %}
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}"
data-slot="{{ pos.slot.slot_number }}">
<span class="slot-number">{{ pos.slot.slot_number }}</span>
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
{% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
{% if is_last_slot %}
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
{% else %}
<button type="submit" class="btn btn-confirm">OK</button>
{% endif %}
</form>
{% elif carte_active and pos.slot.status == 'EMPTY' and pos.slot.slot_number == carte_next_slot_number %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ pos.slot.slot_number }}">
<button type="submit" class="drop-token-btn btn btn-confirm" aria-label="Fill slot {{ pos.slot.slot_number }}">OK</button>
</form>
{% elif carte_active and pos.slot.status == 'FILLED' and pos.slot.slot_number == carte_nvm_slot_number %}
<form method="POST" action="{% url 'epic:release_slot' room.id %}">
{% csrf_token %}
<input type="hidden" name="slot_number" value="{{ pos.slot.slot_number }}">
<button type="submit" class="slot-release-btn btn btn-cancel">NVM</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -15,7 +15,8 @@
{% if room.table_status == "ROLE_SELECT" and card_stack_state %} {% if room.table_status == "ROLE_SELECT" and card_stack_state %}
<div class="card-stack" data-state="{{ card_stack_state }}" <div class="card-stack" data-state="{{ card_stack_state }}"
data-starter-roles="{{ starter_roles|join:',' }}" data-starter-roles="{{ starter_roles|join:',' }}"
data-user-slots="{{ user_slots|join:',' }}"> data-user-slots="{{ user_slots|join:',' }}"
data-active-slot="{{ active_slot }}">
{% if card_stack_state == "ineligible" %} {% if card_stack_state == "ineligible" %}
<i class="fa-solid fa-ban"></i> <i class="fa-solid fa-ban"></i>
{% endif %} {% endif %}
@@ -35,44 +36,20 @@
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
{% for slot in room.gate_slots.all %} {% for pos in gate_positions %}
<div class="table-seat{% if slot.slot_number == active_slot %} active{% endif %}" <div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}"
data-slot="{{ slot.slot_number }}"> data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
<div class="seat-portrait">{{ slot.slot_number }}</div> <i class="fa-solid fa-chair"></i>
<div class="seat-card-arc"></div> <span class="seat-role-label">{{ pos.role_label }}</span>
<span class="seat-label"> {% if pos.role_label in starter_roles %}
{% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %} <i class="position-status-icon fa-solid fa-circle-check"></i>
</span> {% else %}
<i class="position-status-icon fa-solid fa-ban"></i>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</div> </div>
<div id="id_inventory" class="room-inventory">
<div id="id_inv_sig_card"></div>
<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.table_status == "SIG_SELECT" and sig_cards %} {% if room.table_status == "SIG_SELECT" and sig_cards %}
@@ -99,10 +76,14 @@
</div> </div>
{% endif %} {% endif %}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %}
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% 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 %}
<div id="id_tray_wrap"> {% if room.table_status %}
<div id="id_tray_wrap"{% if room.table_status == "ROLE_SELECT" %} class="role-select-phase"{% endif %}>
<div id="id_tray_handle"> <div id="id_tray_handle">
<div id="id_tray_grip"></div> <div id="id_tray_grip"></div>
<button id="id_tray_btn" aria-label="Open seat tray"> <button id="id_tray_btn" aria-label="Open seat tray">
@@ -111,6 +92,7 @@
</div> </div>
<div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div> <div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
</div> </div>
{% endif %}
{% include "apps/gameboard/_partials/_room_gear.html" %} {% include "apps/gameboard/_partials/_room_gear.html" %}
</div> </div>
{% endblock content %} {% endblock content %}