fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename

**CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-18 23:18:32 -04:00
parent 66b2947e8c
commit 39767c72c2
8 changed files with 122 additions and 11 deletions

View File

@@ -1,10 +1,12 @@
"""Seed the My Sign (a.k.a. My Significator) applet on billboard.
Sprint 4 of the My Sea roadmap. "Significator" remains the storage-layer
term (User.significator FK, room sig-select) this billboard surface is
branded "Game Sign". 4×6 (narrow + tall), seeded after all other billboard
applets so it renders at the end of the billboard grid. Shows the user's
saved significator card or a blank state.
term (User.significator FK, room sig-select); the applet itself is "My
Sign" (the "My X" convention for applet names) while the standalone page
wordmark reads "Game Sign" (the "Game/Dash/Bill X" convention for pages).
4×6 (narrow + tall), seeded after all other billboard applets so it
renders at the end of the billboard grid. Shows the user's saved
significator card or a blank state.
"""
from django.db import migrations
@@ -14,7 +16,7 @@ def seed(apps, schema_editor):
Applet.objects.update_or_create(
slug="my-sign",
defaults={
"name": "Game Sign",
"name": "My Sign",
"context": "billboard",
"default_visible": True,
"grid_cols": 4,

View File

@@ -0,0 +1,31 @@
"""Update the My Sign applet's display name — "Game Sign""My Sign".
User clarified naming convention 2026-05-18: **applets** use the "My X"
prefix (My Sign, My Sea, My Posts, etc.) while **standalone pages** use
the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit
page). Sprint 4a's initial migration (0009) set the applet name to
"Game Sign", which the user corrected after seeing the gear-menu toggle
list show the wrong word. Slug stays `my-sign` (URL + selectors stable).
"""
from django.db import migrations
def rename(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sign").update(name="My Sign")
def unrename(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug="my-sign").update(name="Game Sign")
class Migration(migrations.Migration):
dependencies = [
("applets", "0009_seed_my_sig_applet"),
]
operations = [
migrations.RunPython(rename, unrename),
]

View File

@@ -15,7 +15,7 @@ def _seed_billboard_applets():
("my-scrolls", "My Scrolls", 4, 3),
("my-buds", "My Buds", 4, 3),
("most-recent-scroll", "Most Recent Scroll", 8, 6),
("my-sign", "Game Sign", 4, 6),
("my-sign", "My Sign", 4, 6),
]:
Applet.objects.get_or_create(
slug=slug,

View File

@@ -877,6 +877,68 @@ class SelectRoleMultiSeatTest(TestCase):
self.founder.refresh_from_db()
self.assertIsNone(self.founder.equipped_deck) # still None, not broken
# ── Return-trip Role-Select bug — CARTE user navigates away + back ───── #
#
# After the FIRST role pick, select_role() clears user.equipped_deck
# ("deck committed to room"). The room view's role-select context then
# passes `equipped_deck_id = user.equipped_deck_id` to the template,
# which sets `data-equipped-deck=""` and JS guards against role-select
# with "Equip card deck before Role select." → blocks a CARTE user from
# continuing to pick roles for their remaining seats. Fix: the context's
# `equipped_deck_id` should also accept the deck_variant of any seat the
# user already holds in this room (the deck IS in play — it's just
# committed to existing seats, not to user.equipped_deck).
def test_role_select_context_recovers_deck_id_from_existing_seat(self):
"""User cleared their equipped_deck after first role pick, but they
still have a seat in this room w. deck_variant set → context should
report that deck's id so the guard doesn't fire on return."""
TableSeat.objects.create(
room=self.room, gamer=self.founder, slot_number=1,
role="PC", deck_variant=self.earthman,
)
# Slot 2 still needs role (CARTE user's next seat)
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
self.founder.equipped_deck = None
self.founder.save(update_fields=["equipped_deck"])
# Simulate filled gate slots so the room renders in role-select state
for i in (1, 2):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = self.founder
slot.status = GateSlot.FILLED
slot.save()
response = self.client.get(
reverse("epic:room", kwargs={"room_id": self.room.id})
)
self.assertEqual(
response.context["equipped_deck_id"], self.earthman.id,
"Returning CARTE user should see their in-play deck reflected in "
"the role-select context so the JS guard doesn't fire.",
)
def test_role_select_context_renders_data_equipped_deck_non_empty(self):
"""Template-level check — the rendered `data-equipped-deck` attribute
should be non-empty so the JS guard at role-select.js:165 lets the
fan open."""
TableSeat.objects.create(
room=self.room, gamer=self.founder, slot_number=1,
role="PC", deck_variant=self.earthman,
)
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
self.founder.equipped_deck = None
self.founder.save(update_fields=["equipped_deck"])
for i in (1, 2):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = self.founder
slot.status = GateSlot.FILLED
slot.save()
response = self.client.get(
reverse("epic:room", kwargs={"room_id": self.room.id})
)
# Should NOT contain the empty-string version that triggers the guard.
self.assertNotContains(response, 'data-equipped-deck=""')
self.assertContains(response, f'data-equipped-deck="{self.earthman.id}"')
class RoomViewAllRolesFilledTest(TestCase):
"""Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""

View File

@@ -281,9 +281,25 @@ def _role_select_context(room, user):
)
active_slot = active_seat.slot_number if active_seat else None
_my_role = assigned_seats[0].role if assigned_seats else None
# `equipped_deck_id` gates the JS role-select guard (role-select.js L165).
# Falls back to ANY of the user's seats in this room w. deck_variant set
# — covers the CARTE multi-seat return path where the first role-pick
# cleared `user.equipped_deck` (the deck is committed to seats, but still
# "in play"). Without this, a CARTE user navigating away + back gets
# "Equip card deck before Role select" wrongly fired. See [[sprint-carte-
# role-select-return-may18]] / commit-TBD for the bug trail.
role_select_deck_id = (
user.equipped_deck_id if user.is_authenticated else None
)
if user.is_authenticated and not role_select_deck_id:
seat_w_deck = room.table_seats.filter(
gamer=user, deck_variant__isnull=False,
).order_by("slot_number").first()
if seat_w_deck:
role_select_deck_id = seat_w_deck.deck_variant_id
ctx = {
"card_stack_state": card_stack_state,
"equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None,
"equipped_deck_id": role_select_deck_id,
"starter_roles": starter_roles,
"assigned_seats": assigned_seats,
"my_tray_role": _my_role,

View File

@@ -18,7 +18,7 @@ from apps.lyric.models import User
def _seed_my_sign_applet():
Applet.objects.get_or_create(
slug="my-sign",
defaults={"name": "Game Sign", "context": "billboard",
defaults={"name": "My Sign", "context": "billboard",
"default_visible": True, "grid_cols": 4, "grid_rows": 6},
)

View File

@@ -443,14 +443,14 @@
/* Baltimore Palette */
.palette-baltimore {
--priUser: var(--sepBlt);
--secUser: var(--terBlt);
--secUser: var(--sixBlt);
--terUser: var(--ninBlt);
--quaUser: var(--priBlt);
--quiUser: var(--terMze);
--sixUser: var(--quiBlt);
--sepUser: var(--quiBlt);
--octUser: var(--quiBlt);
--ninUser: var(--sixBlt);
--ninUser: var(--terBlt);
--decUser: var(--quiBlt);
}

View File

@@ -2,7 +2,7 @@
id="id_applet_my_sign"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>
<h2><a href="{% url 'billboard:my_sign' %}">My Sign</a></h2>
<div class="my-sign-applet-body">
{% if request.user.significator %}
{% with card=request.user.significator %}