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:
@@ -1,10 +1,12 @@
|
|||||||
"""Seed the My Sign (a.k.a. My Significator) applet on billboard.
|
"""Seed the My Sign (a.k.a. My Significator) applet on billboard.
|
||||||
|
|
||||||
Sprint 4 of the My Sea roadmap. "Significator" remains the storage-layer
|
Sprint 4 of the My Sea roadmap. "Significator" remains the storage-layer
|
||||||
term (User.significator FK, room sig-select) — this billboard surface is
|
term (User.significator FK, room sig-select); the applet itself is "My
|
||||||
branded "Game Sign". 4×6 (narrow + tall), seeded after all other billboard
|
Sign" (the "My X" convention for applet names) while the standalone page
|
||||||
applets so it renders at the end of the billboard grid. Shows the user's
|
wordmark reads "Game Sign" (the "Game/Dash/Bill X" convention for pages).
|
||||||
saved significator card or a blank state.
|
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
|
from django.db import migrations
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ def seed(apps, schema_editor):
|
|||||||
Applet.objects.update_or_create(
|
Applet.objects.update_or_create(
|
||||||
slug="my-sign",
|
slug="my-sign",
|
||||||
defaults={
|
defaults={
|
||||||
"name": "Game Sign",
|
"name": "My Sign",
|
||||||
"context": "billboard",
|
"context": "billboard",
|
||||||
"default_visible": True,
|
"default_visible": True,
|
||||||
"grid_cols": 4,
|
"grid_cols": 4,
|
||||||
|
|||||||
31
src/apps/applets/migrations/0010_rename_my_sign_applet.py
Normal file
31
src/apps/applets/migrations/0010_rename_my_sign_applet.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -15,7 +15,7 @@ def _seed_billboard_applets():
|
|||||||
("my-scrolls", "My Scrolls", 4, 3),
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
("my-buds", "My Buds", 4, 3),
|
("my-buds", "My Buds", 4, 3),
|
||||||
("most-recent-scroll", "Most Recent Scroll", 8, 6),
|
("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(
|
Applet.objects.get_or_create(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|||||||
@@ -877,6 +877,68 @@ class SelectRoleMultiSeatTest(TestCase):
|
|||||||
self.founder.refresh_from_db()
|
self.founder.refresh_from_db()
|
||||||
self.assertIsNone(self.founder.equipped_deck) # still None, not broken
|
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):
|
class RoomViewAllRolesFilledTest(TestCase):
|
||||||
"""Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""
|
"""Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""
|
||||||
|
|||||||
@@ -281,9 +281,25 @@ def _role_select_context(room, user):
|
|||||||
)
|
)
|
||||||
active_slot = active_seat.slot_number if active_seat else None
|
active_slot = active_seat.slot_number if active_seat else None
|
||||||
_my_role = assigned_seats[0].role if assigned_seats 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 = {
|
ctx = {
|
||||||
"card_stack_state": card_stack_state,
|
"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,
|
"starter_roles": starter_roles,
|
||||||
"assigned_seats": assigned_seats,
|
"assigned_seats": assigned_seats,
|
||||||
"my_tray_role": _my_role,
|
"my_tray_role": _my_role,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from apps.lyric.models import User
|
|||||||
def _seed_my_sign_applet():
|
def _seed_my_sign_applet():
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
slug="my-sign",
|
slug="my-sign",
|
||||||
defaults={"name": "Game Sign", "context": "billboard",
|
defaults={"name": "My Sign", "context": "billboard",
|
||||||
"default_visible": True, "grid_cols": 4, "grid_rows": 6},
|
"default_visible": True, "grid_cols": 4, "grid_rows": 6},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -443,14 +443,14 @@
|
|||||||
/* Baltimore Palette */
|
/* Baltimore Palette */
|
||||||
.palette-baltimore {
|
.palette-baltimore {
|
||||||
--priUser: var(--sepBlt);
|
--priUser: var(--sepBlt);
|
||||||
--secUser: var(--terBlt);
|
--secUser: var(--sixBlt);
|
||||||
--terUser: var(--ninBlt);
|
--terUser: var(--ninBlt);
|
||||||
--quaUser: var(--priBlt);
|
--quaUser: var(--priBlt);
|
||||||
--quiUser: var(--terMze);
|
--quiUser: var(--terMze);
|
||||||
--sixUser: var(--quiBlt);
|
--sixUser: var(--quiBlt);
|
||||||
--sepUser: var(--quiBlt);
|
--sepUser: var(--quiBlt);
|
||||||
--octUser: var(--quiBlt);
|
--octUser: var(--quiBlt);
|
||||||
--ninUser: var(--sixBlt);
|
--ninUser: var(--terBlt);
|
||||||
--decUser: var(--quiBlt);
|
--decUser: var(--quiBlt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
id="id_applet_my_sign"
|
id="id_applet_my_sign"
|
||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
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">
|
<div class="my-sign-applet-body">
|
||||||
{% if request.user.significator %}
|
{% if request.user.significator %}
|
||||||
{% with card=request.user.significator %}
|
{% with card=request.user.significator %}
|
||||||
|
|||||||
Reference in New Issue
Block a user