diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js
index 011a704..401a439 100644
--- a/src/apps/epic/static/apps/epic/role-select.js
+++ b/src/apps/epic/static/apps/epic/role-select.js
@@ -41,6 +41,29 @@ var RoleSelect = (function () {
if (backdrop) backdrop.remove();
}
+ function _showNoDeckWarning(stack) {
+ if (document.querySelector(".role-no-deck-warning")) return;
+ var el = document.createElement("div");
+ el.className = "role-no-deck-warning";
+ el.innerHTML =
+ "
Equip card deck before Role select
" +
+ "" +
+ "
FYI" +
+ "
" +
+ "
";
+ el.querySelector(".btn-cancel").addEventListener("click", function () {
+ el.remove();
+ });
+ if (stack) {
+ var rect = stack.getBoundingClientRect();
+ el.style.position = "fixed";
+ el.style.left = Math.round(rect.left + rect.width / 2) + "px";
+ el.style.top = Math.round(rect.top + rect.height / 2) + "px";
+ el.style.transform = "translate(-50%, -50%)";
+ }
+ document.body.appendChild(el);
+ }
+
function selectRole(roleCode) {
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
closeFan();
@@ -138,6 +161,12 @@ var RoleSelect = (function () {
function openFan() {
if (document.querySelector(".role-select-backdrop")) return;
+ var stack = document.querySelector(".card-stack[data-starter-roles]");
+ if (stack && stack.dataset.equippedDeck === "") {
+ _showNoDeckWarning(stack);
+ return;
+ }
+
var taken = getStarterRoles();
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py
index b654a94..a8c238b 100644
--- a/src/apps/epic/views.py
+++ b/src/apps/epic/views.py
@@ -276,6 +276,7 @@ def _role_select_context(room, user):
_my_role = assigned_seats[0].role if assigned_seats else None
ctx = {
"card_stack_state": card_stack_state,
+ "equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None,
"starter_roles": starter_roles,
"assigned_seats": assigned_seats,
"my_tray_role": _my_role,
diff --git a/src/static/tests/RoleSelectSpec.js b/src/static/tests/RoleSelectSpec.js
index eae2db3..3e39fff 100644
--- a/src/static/tests/RoleSelectSpec.js
+++ b/src/static/tests/RoleSelectSpec.js
@@ -21,6 +21,8 @@ describe("RoleSelect", () => {
afterEach(() => {
RoleSelect.closeFan();
testDiv.remove();
+ const warning = document.querySelector(".role-no-deck-warning");
+ if (warning) warning.remove();
delete window.showGuard;
});
@@ -693,4 +695,87 @@ describe("RoleSelect", () => {
});
});
});
+
+ // ------------------------------------------------------------------ //
+ // No-deck guard //
+ // data-equipped-deck="" → openFan() blocked; warning shown over stack //
+ // ------------------------------------------------------------------ //
+
+ describe("openFan() — no deck equipped (data-equipped-deck='')", () => {
+ let stack;
+
+ beforeEach(() => {
+ stack = document.createElement("div");
+ stack.className = "card-stack";
+ stack.dataset.state = "eligible";
+ stack.dataset.starterRoles = "";
+ stack.dataset.userSlots = "1";
+ stack.dataset.activeSlot = "1";
+ stack.dataset.equippedDeck = ""; // explicitly empty = no deck
+ testDiv.appendChild(stack);
+
+ RoleSelect.openFan(); // intercepted — fan must NOT open
+ });
+
+ it("does not open the fan backdrop", () => {
+ expect(document.querySelector(".role-select-backdrop")).toBeNull();
+ });
+
+ it("shows .role-no-deck-warning in the DOM", () => {
+ expect(document.querySelector(".role-no-deck-warning")).not.toBeNull();
+ });
+
+ it("warning contains the deck prompt text", () => {
+ const w = document.querySelector(".role-no-deck-warning");
+ expect(w.textContent).toContain("Equip card deck before Role select");
+ });
+
+ it("warning has a .btn-caution FYI link to gameboard", () => {
+ const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution");
+ expect(btn).not.toBeNull();
+ expect(btn.tagName).toBe("A");
+ expect(btn.href).toContain("/gameboard/");
+ });
+
+ it("warning has a .btn-cancel NVM button", () => {
+ expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull();
+ });
+
+ it("NVM click removes the warning", () => {
+ document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click();
+ expect(document.querySelector(".role-no-deck-warning")).toBeNull();
+ });
+
+ it("second openFan() call does not duplicate the warning", () => {
+ RoleSelect.openFan();
+ expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1);
+ });
+
+ it("does not POST to select_role", () => {
+ expect(window.fetch).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => {
+ beforeEach(() => {
+ const stack = document.createElement("div");
+ stack.className = "card-stack";
+ stack.dataset.state = "eligible";
+ stack.dataset.starterRoles = "";
+ stack.dataset.userSlots = "1";
+ stack.dataset.activeSlot = "1";
+ stack.dataset.equippedDeck = "42"; // non-empty = deck present
+ testDiv.appendChild(stack);
+
+ RoleSelect.openFan();
+ });
+
+ it("opens the fan backdrop normally", () => {
+ expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
+ });
+
+ it("does not show .role-no-deck-warning", () => {
+ expect(document.querySelector(".role-no-deck-warning")).toBeNull();
+ });
+ });
});
diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js
index 3859d1b..33065c8 100644
--- a/src/static/tests/SigSelectSpec.js
+++ b/src/static/tests/SigSelectSpec.js
@@ -58,7 +58,9 @@ describe("SigSelect", () => {
data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences"
- data-cautions="${cardCautions.replace(/"/g, '"')}">
+ data-cautions="${cardCautions.replace(/"/g, '"')}"
+ data-levity-qualifier="Elevated"
+ data-gravity-qualifier="Graven">
K
@@ -540,25 +542,25 @@ describe("SigSelect", () => {
// ── Polarity theming — stage qualifier text ────────────────────────────── //
//
- // On mouseenter, updateStage() injects "Leavened" or "Graven" into the
+ // On mouseenter, updateStage() injects "Elevated" or "Graven" into the
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
// Correspondence field is never populated in sig-select context.
describe("polarity theming — stage qualifier", () => {
- it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => {
+ it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' });
// data-arcana defaults to "Minor Arcana" in fixture → non-major
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
- expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened");
+ expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
});
- it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
+ it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' });
card.dataset.arcana = "Major Arcana";
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
- expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
+ expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
@@ -597,7 +599,7 @@ describe("SigSelect", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
// Now major — above should be empty, below filled
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
- expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
+ expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
it("correspondence field is never populated", () => {
diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss
index 32802b2..7d206a2 100644
--- a/src/static_src/scss/_base.scss
+++ b/src/static_src/scss/_base.scss
@@ -563,7 +563,7 @@ body {
z-index: 10000;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
- background-color: rgba(var(--tooltip-bg), 0.5);
+ background-color: rgba(var(--tooltip-bg), 0.75);
backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss
index d2dcbb8..c449c52 100644
--- a/src/static_src/scss/_room.scss
+++ b/src/static_src/scss/_room.scss
@@ -738,6 +738,37 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
$card-w: 90px;
$card-h: 60px;
+// ─── No-deck warning overlay ──────────────────────────────────────────────
+
+.role-no-deck-warning {
+ position: fixed;
+ z-index: 10000;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ max-width: 11rem;
+ border-radius: 0.5rem;
+ background-color: rgba(var(--tooltip-bg), 0.75);
+ backdrop-filter: blur(6px);
+ border: 0.1rem solid rgba(var(--secUser), 0.4);
+ box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
+
+ p {
+ font-size: 0.75rem;
+ color: rgba(var(--secUser), 0.9);
+ text-align: center;
+ margin: 0;
+ }
+
+ .guard-actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+}
+
// ─── Role select modal ─────────────────────────────────────────────────────
.role-select-backdrop {
diff --git a/src/static_src/tests/RoleSelectSpec.js b/src/static_src/tests/RoleSelectSpec.js
index eae2db3..3e39fff 100644
--- a/src/static_src/tests/RoleSelectSpec.js
+++ b/src/static_src/tests/RoleSelectSpec.js
@@ -21,6 +21,8 @@ describe("RoleSelect", () => {
afterEach(() => {
RoleSelect.closeFan();
testDiv.remove();
+ const warning = document.querySelector(".role-no-deck-warning");
+ if (warning) warning.remove();
delete window.showGuard;
});
@@ -693,4 +695,87 @@ describe("RoleSelect", () => {
});
});
});
+
+ // ------------------------------------------------------------------ //
+ // No-deck guard //
+ // data-equipped-deck="" → openFan() blocked; warning shown over stack //
+ // ------------------------------------------------------------------ //
+
+ describe("openFan() — no deck equipped (data-equipped-deck='')", () => {
+ let stack;
+
+ beforeEach(() => {
+ stack = document.createElement("div");
+ stack.className = "card-stack";
+ stack.dataset.state = "eligible";
+ stack.dataset.starterRoles = "";
+ stack.dataset.userSlots = "1";
+ stack.dataset.activeSlot = "1";
+ stack.dataset.equippedDeck = ""; // explicitly empty = no deck
+ testDiv.appendChild(stack);
+
+ RoleSelect.openFan(); // intercepted — fan must NOT open
+ });
+
+ it("does not open the fan backdrop", () => {
+ expect(document.querySelector(".role-select-backdrop")).toBeNull();
+ });
+
+ it("shows .role-no-deck-warning in the DOM", () => {
+ expect(document.querySelector(".role-no-deck-warning")).not.toBeNull();
+ });
+
+ it("warning contains the deck prompt text", () => {
+ const w = document.querySelector(".role-no-deck-warning");
+ expect(w.textContent).toContain("Equip card deck before Role select");
+ });
+
+ it("warning has a .btn-caution FYI link to gameboard", () => {
+ const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution");
+ expect(btn).not.toBeNull();
+ expect(btn.tagName).toBe("A");
+ expect(btn.href).toContain("/gameboard/");
+ });
+
+ it("warning has a .btn-cancel NVM button", () => {
+ expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull();
+ });
+
+ it("NVM click removes the warning", () => {
+ document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click();
+ expect(document.querySelector(".role-no-deck-warning")).toBeNull();
+ });
+
+ it("second openFan() call does not duplicate the warning", () => {
+ RoleSelect.openFan();
+ expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1);
+ });
+
+ it("does not POST to select_role", () => {
+ expect(window.fetch).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => {
+ beforeEach(() => {
+ const stack = document.createElement("div");
+ stack.className = "card-stack";
+ stack.dataset.state = "eligible";
+ stack.dataset.starterRoles = "";
+ stack.dataset.userSlots = "1";
+ stack.dataset.activeSlot = "1";
+ stack.dataset.equippedDeck = "42"; // non-empty = deck present
+ testDiv.appendChild(stack);
+
+ RoleSelect.openFan();
+ });
+
+ it("opens the fan backdrop normally", () => {
+ expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
+ });
+
+ it("does not show .role-no-deck-warning", () => {
+ expect(document.querySelector(".role-no-deck-warning")).toBeNull();
+ });
+ });
});
diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js
index 3859d1b..33065c8 100644
--- a/src/static_src/tests/SigSelectSpec.js
+++ b/src/static_src/tests/SigSelectSpec.js
@@ -58,7 +58,9 @@ describe("SigSelect", () => {
data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences"
- data-cautions="${cardCautions.replace(/"/g, '"')}">
+ data-cautions="${cardCautions.replace(/"/g, '"')}"
+ data-levity-qualifier="Elevated"
+ data-gravity-qualifier="Graven">
K
@@ -540,25 +542,25 @@ describe("SigSelect", () => {
// ── Polarity theming — stage qualifier text ────────────────────────────── //
//
- // On mouseenter, updateStage() injects "Leavened" or "Graven" into the
+ // On mouseenter, updateStage() injects "Elevated" or "Graven" into the
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
// Correspondence field is never populated in sig-select context.
describe("polarity theming — stage qualifier", () => {
- it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => {
+ it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' });
// data-arcana defaults to "Minor Arcana" in fixture → non-major
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
- expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened");
+ expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
});
- it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
+ it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' });
card.dataset.arcana = "Major Arcana";
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
- expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
+ expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
@@ -597,7 +599,7 @@ describe("SigSelect", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
// Now major — above should be empty, below filled
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
- expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
+ expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
});
it("correspondence field is never populated", () => {
diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html
index 0c70677..7808c1d 100644
--- a/src/templates/apps/gameboard/room.html
+++ b/src/templates/apps/gameboard/room.html
@@ -27,7 +27,8 @@
+ data-active-slot="{{ active_slot }}"
+ data-equipped-deck="{{ equipped_deck_id|default:'' }}">
{% if card_stack_state == "ineligible" %}
{% endif %}