", idx)
+ if needle in content[idx:end]:
+ return idx
+ pos = end
+
+
+def _circle_tag(content, slot_number):
+ """Return the opening `
` tag of the gate-slot circle for the
+ given slot — class + every data-tt-* attr live on this one tag."""
+ idx = _circle_start(content, slot_number)
+ return content[idx:content.index(">", idx)]
+
+
+class PositionTooltipRenderTest(TestCase):
+ """Render-level coverage for the rich position-circle tooltip payload
+ (sprint 2026-06-02) — the fast IT counterpart to the Selenium
+ PositionTooltipTest in functional_tests/test_game_room_position_tooltips.py.
+ Exercised on the GATE VIEW (room_gate), which rendered no circles before
+ this sprint."""
+
+ def setUp(self):
+ from apps.epic.models import DeckVariant
+ self.viewer = User.objects.create(email="disco@test.io", username="disco")
+ self.deck, _ = DeckVariant.objects.get_or_create(
+ slug="earthman",
+ defaults={"name": "Earthman", "card_count": 106, "is_default": True},
+ )
+ self.viewer.equipped_deck = self.deck
+ self.viewer.save(update_fields=["equipped_deck"])
+ self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
+ self.gamers = [self.viewer]
+ for i in range(2, 7):
+ self.gamers.append(
+ User.objects.create(email=f"g{i}@test.io", username=f"g{i}")
+ )
+ for i, gamer in enumerate(self.gamers, start=1):
+ slot = self.room.gate_slots.get(slot_number=i)
+ slot.gamer = gamer
+ slot.status = GateSlot.FILLED
+ slot.filled_at = timezone.now()
+ slot.debited_token_type = Token.FREE
+ slot.save()
+ self.room.gate_status = Room.OPEN
+ self.room.table_status = Room.ROLE_SELECT
+ self.room.save()
+ self.gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
+ self.client.force_login(self.viewer)
+
+ def _gate_content(self):
+ return self.client.get(self.gate_url).content.decode()
+
+ def test_gate_view_renders_six_position_circles(self):
+ content = self._gate_content()
+ self.assertContains(self.client.get(self.gate_url), "position-strip")
+ self.assertEqual(content.count('class="gate-slot'), 6)
+
+ def test_own_slot_is_me_current_others_are_gamer(self):
+ content = self._gate_content()
+ self.assertIn("tt-pos-me-current", _circle_tag(content, 1))
+ slot2 = _circle_tag(content, 2)
+ self.assertIn("tt-pos-gamer", slot2)
+ self.assertNotIn("tt-pos-me", slot2)
+
+ def test_other_gamer_handle_in_title_not_email(self):
+ slot2 = _circle_tag(self._gate_content(), 2)
+ self.assertIn('data-tt-title="@g2"', slot2)
+ # No email field in the tooltip payload (user-spec).
+ self.assertNotIn("data-tt-email", slot2)
+ self.assertIn("data-tt-description", slot2)
+
+ def test_bud_occupant_carries_bud_class_and_shoptalk(self):
+ from apps.billboard.models import BudshipNote
+ amigo = self.gamers[1]
+ self.viewer.buds.add(amigo)
+ BudshipNote.objects.create(user=self.viewer, bud=amigo, shoptalk="met at the deli")
+ slot2 = _circle_tag(self._gate_content(), 2)
+ self.assertIn("tt-pos-bud", slot2)
+ self.assertIn('data-tt-shoptalk="met at the deli"', slot2)
+
+ def test_deposit_count_and_expiry_present(self):
+ slot2 = _circle_tag(self._gate_content(), 2)
+ self.assertIn('data-tt-tokens="1"', slot2)
+ self.assertIn("data-tt-expiry=", slot2)
+
+ def test_seat_significator_rank_rides_the_circle(self):
+ sig = TarotCard.objects.create(
+ deck_variant=self.deck, slug="queen-of-brands-em",
+ arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands",
+ )
+ TableSeat.objects.create(
+ room=self.room, gamer=self.gamers[1], slot_number=2, significator=sig,
+ )
+ slot2 = _circle_tag(self._gate_content(), 2)
+ self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2)
+
+
+class PositionTooltipCarteRenderTest(TestCase):
+ """CARTE-solo render contract: a single gamer owns all six slots — their
+ non-current circles read tt-pos-me-also + carry a ?seat=N switch href, and
+ the deposited count reflects the CARTE token's slots_claimed."""
+
+ def setUp(self):
+ from apps.epic.models import DeckVariant
+ self.viewer = User.objects.create(email="disco@test.io", username="disco")
+ self.deck, _ = DeckVariant.objects.get_or_create(
+ slug="earthman",
+ defaults={"name": "Earthman", "card_count": 106, "is_default": True},
+ )
+ self.viewer.equipped_deck = self.deck
+ self.viewer.save(update_fields=["equipped_deck"])
+ self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
+ self.room.gate_slots.update(
+ gamer=self.viewer, status=GateSlot.FILLED,
+ filled_at=timezone.now(), debited_token_type=Token.CARTE,
+ )
+ Token.objects.create(
+ user=self.viewer, token_type=Token.CARTE,
+ current_room=self.room, slots_claimed=6,
+ )
+ self.room.gate_status = Room.OPEN
+ self.room.table_status = Room.ROLE_SELECT
+ self.room.save()
+ self.client.force_login(self.viewer)
+ self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
+
+ def test_tokens_reflects_carte_slots_claimed(self):
+ content = self.client.get(self.room_url).content.decode()
+ self.assertIn('data-tt-tokens="6"', _circle_tag(content, 1))
+
+ def test_own_other_seat_is_me_also_with_switch_href(self):
+ content = self.client.get(self.room_url).content.decode()
+ slot4 = _circle_tag(content, 4)
+ self.assertIn("tt-pos-me-also", slot4)
+ # The switch anchor lands just after the opening tag.
+ idx = _circle_start(content, 4)
+ chunk = content[idx:idx + 800]
+ self.assertIn("seat=4", chunk)
+
+
class PickRolesViewTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")
diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py
index c312601..4c428ea 100644
--- a/src/apps/epic/views.py
+++ b/src/apps/epic/views.py
@@ -178,19 +178,94 @@ _ROLE_SCRAWL_NAMES = {
}
-def _gate_positions(room):
- """Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
+def _viewer_current_slot(room, user, seat_param=None):
+ """The slot the viewer is currently "acting as": a ?seat=N override when
+ they own that slot, else their lowest-numbered owned slot (the canonical
+ seat). None for anon / non-seated viewers. Splits the viewer's own
+ position circles into me-current (this slot) vs me-also (their other
+ CARTE-claimed slots)."""
+ if not getattr(user, "is_authenticated", False):
+ return None
+ owned = sorted(
+ room.gate_slots.filter(gamer=user).values_list("slot_number", flat=True)
+ )
+ if not owned:
+ return None
+ if seat_param:
+ try:
+ n = int(seat_param)
+ except (TypeError, ValueError):
+ n = None
+ if n in owned:
+ return n
+ return owned[0]
+
+
+def _gate_positions(room, user=None, current_slot=None):
+ """Return list of per-circle dicts for _table_positions.html.
+
+ Carries the legacy keys (slot, role_label, role_assigned) PLUS the rich
+ tooltip payload (sprint 2026-06-02): a `.tt-pos-*` state class classifying
+ the occupant relative to `user`, the deposited-token count, the seat-clock
+ expiry, the seat significator rank/suit, and bud shoptalk. `@handle` +
+ title are read off `pos.slot.gamer` in the template (at_handle /
+ active_title_display). `current_slot` is the viewer's acting seat
+ (`_viewer_current_slot`) — it splits the viewer's own circles me-current
+ vs me-also."""
# 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 [
- {
+ seats_by_slot = {s.slot_number: s for s in room.table_seats.all()}
+ authed = getattr(user, "is_authenticated", False)
+ bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set()
+ shoptalk_map = {}
+ if authed:
+ from apps.billboard.models import BudshipNote
+ shoptalk_map = {
+ bn.bud_id: bn.shoptalk
+ for bn in BudshipNote.objects.filter(user=user)
+ }
+ positions = []
+ for slot in room.gate_slots.order_by("slot_number"):
+ gamer = slot.gamer
+ seat = seats_by_slot.get(slot.slot_number)
+ is_self = authed and gamer is not None and gamer.id == user.id
+ is_bud = (
+ authed and gamer is not None and not is_self
+ and gamer.id in bud_ids
+ )
+ is_me_also = is_self and slot.slot_number != current_slot
+ if gamer is None:
+ state_class = "tt-pos-empty"
+ elif is_self:
+ state_class = "tt-pos-me-also" if is_me_also else "tt-pos-me-current"
+ elif is_bud:
+ state_class = "tt-pos-gamer tt-pos-bud"
+ else:
+ state_class = "tt-pos-gamer"
+ # Deposited-token count — CARTE claims many slots on one token.
+ tokens = 1
+ if gamer is not None and slot.debited_token_type == Token.CARTE:
+ carte = gamer.tokens.filter(
+ token_type=Token.CARTE, current_room=room
+ ).first()
+ tokens = carte.slots_claimed if carte else 1
+ sig = seat.significator if seat else None
+ positions.append({
"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")
- ]
+ "state_class": state_class,
+ "is_self": is_self,
+ "is_bud": is_bud,
+ "is_me_also": is_me_also,
+ "shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
+ "tokens": tokens,
+ "expiry": slot.cost_current_until,
+ "sign_rank": sig.corner_rank if sig else "",
+ "sign_suit_icon": sig.suit_icon if sig else "",
+ })
+ return positions
def _expire_reserved_slots(room):
@@ -250,7 +325,7 @@ def _expire_lapsed_seats(room):
room.save(update_fields=["gate_status"])
-def _gate_context(room, user):
+def _gate_context(room, user, seat_param=None):
_expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number")
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
@@ -306,12 +381,15 @@ def _gate_context(room, user):
"carte_slots_claimed": carte_slots_claimed,
"carte_nvm_slot_number": carte_nvm_slot_number,
"carte_next_slot_number": carte_next_slot_number,
- "gate_positions": _gate_positions(room),
+ "gate_positions": _gate_positions(
+ room, user, _viewer_current_slot(room, user, seat_param)
+ ),
"starter_roles": [],
}
-def _role_select_context(room, user):
+def _role_select_context(room, user, seat_param=None):
+ current_slot = _viewer_current_slot(room, user, seat_param)
user_seat = None
active_seat = None
unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
@@ -391,7 +469,7 @@ def _role_select_context(room, user):
.values_list("slot_number", flat=True)
) if user.is_authenticated else [],
"active_slot": active_slot,
- "gate_positions": _gate_positions(room),
+ "gate_positions": _gate_positions(room, user, current_slot),
"slots": room.gate_slots.order_by("slot_number"),
}
# Viewer's seat token-cost state — drives the center-hex GATE VIEW
@@ -511,7 +589,7 @@ def gatekeeper(request, room_id):
def room_view(request, room_id):
room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
- ctx = _role_select_context(room, request.user)
+ ctx = _role_select_context(room, request.user, request.GET.get("seat"))
ctx["room"] = room
# `page-room` drives the navbar GATE VIEW swap (mirrors my-sea's
# `page-my-sea`) so the table page reaches the renewal gate-view instead
@@ -541,12 +619,19 @@ def room_gate(request, room_id):
user_slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.FILLED
).first()
- return render(request, "apps/gameboard/room_gate.html", {
+ # Merge the gatekeeper gate-context so the position circles
+ # (_table_positions.html) render here too — it supplies gate_positions
+ # (now rich w. the .tt-pos-* tooltip payload) + every carte_* key the
+ # partial's slot conditionals read. The renewal modal's own keys override
+ # below.
+ ctx = _gate_context(room, request.user, request.GET.get("seat"))
+ ctx.update({
"room": room,
"cost_current": user_slot.cost_current if user_slot else True,
"deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(),
"page_class": "page-gameboard page-room page-room-gate",
})
+ return render(request, "apps/gameboard/room_gate.html", ctx)
@login_required
diff --git a/src/functional_tests/test_game_room_position_tooltips.py b/src/functional_tests/test_game_room_position_tooltips.py
index 4e99040..d81d7f1 100644
--- a/src/functional_tests/test_game_room_position_tooltips.py
+++ b/src/functional_tests/test_game_room_position_tooltips.py
@@ -55,7 +55,6 @@ def _gate_view_url(self, room):
return self.live_server_url + f"/gameboard/room/{room.id}/gate/view/"
-@skip(_RED)
class PositionTooltipTest(FunctionalTest):
"""Tooltip CONTENT + state classes on the gate-position circles, exercised
on the new GATE VIEW gate-view (the clearest red surface — it renders no
@@ -72,6 +71,13 @@ class PositionTooltipTest(FunctionalTest):
["disco@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io"],
)
+ # Every occupant needs a stable @handle (not email-derived) so the
+ # tooltip title reads "@amigo", never "amigo@test.io" — the spec
+ # forbids leaking the email into the title.
+ for g in self.gamers:
+ if not g.username:
+ g.username = g.email.split("@")[0]
+ g.save(update_fields=["username"])
# Stamp filled_at so cost_current_until (the .tt-expiry) is real.
self.room.gate_slots.filter(status=GateSlot.FILLED).update(
filled_at=timezone.now(), debited_token_type=Token.FREE,
@@ -165,7 +171,6 @@ class PositionTooltipTest(FunctionalTest):
self.assertNotIn("@test.io", portal.text)
-@skip(_RED)
class CarteSeatSwitchTest(FunctionalTest):
"""A CARTE gamer occupies multiple positions. Their non-current owned
circles read `.tt-pos-me-also` and carry a `?seat=
` switch href that
@@ -221,6 +226,7 @@ class CarteSeatSwitchTest(FunctionalTest):
)
self.assertEqual(circle.get_attribute("data-tt-tokens"), "6")
+ @skip(_RED + " [workstream B — ?seat card-stack consumption]")
def test_switching_seat_loads_that_seats_role_view(self):
# Clicking the me-also seat-4 circle loads ?seat=4 and the card-stack
# reflects seat 4 (a non-active seat → ineligible / .fa-ban atop deck).
@@ -240,6 +246,7 @@ class CarteSeatSwitchTest(FunctionalTest):
self.assertEqual(stack.get_attribute("data-active-slot"), "4")
stack.find_element(By.CSS_SELECTOR, ".fa-ban")
+ @skip(_RED + " [workstream C — solo-group sig persistence]")
def test_carte_saves_a_significator_per_seat(self):
# Sig Select: the viewer saves a sig on PC (seat 1), switches to seat 2,
# and saves a DIFFERENT sig there — proving per-seat (not per-gamer) sig.
diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss
index 9418d0a..f9c67a5 100644
--- a/src/static_src/scss/_gameboard.scss
+++ b/src/static_src/scss/_gameboard.scss
@@ -215,6 +215,40 @@ body.page-gameboard {
&.active { display: block; }
}
+// Position-circle tooltip portal — page-root, position:fixed. Distinct id
+// from #id_tooltip_portal (the tray's) so the two hover systems don't share
+// state. position-tooltip.js centres + clamps it under the hovered circle.
+#id_position_tooltip_portal {
+ position: fixed;
+ z-index: 9999;
+ padding: 0.75rem 1.5rem;
+
+ @extend %tt-token-fields;
+
+ .tt-title,
+ .tt-description,
+ .tt-shoptalk,
+ .tt-expiry { display: block; }
+
+ // Seat significator stack — pinned top-right, modeled on the tray sig
+ // card (.fan-corner-rank + fa suit icon) and the .tt-price corner pin.
+ .tt-sign {
+ position: absolute;
+ top: 0.4rem;
+ right: 0.6rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ font-size: 1rem;
+ color: rgba(var(--terUser), 1);
+
+ .fan-corner-rank { font-weight: 700; }
+ i { font-size: 0.85em; }
+ }
+
+ &.active { display: block; }
+}
+
#id_mini_tooltip_portal {
position: fixed;
z-index: 9999;
diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss
index a72b5f0..32813b8 100644
--- a/src/static_src/scss/_room.scss
+++ b/src/static_src/scss/_room.scss
@@ -359,6 +359,10 @@ html:has(.role-select-backdrop) .position-strip .gate-slot { pointer-events: non
// Re-enable clicks on confirm/reject/drop-token forms inside slots
html:has(.gate-backdrop) .position-strip .gate-slot form,
html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: auto; }
+// The room-gate renewal modal renders its OWN .gate-backdrop, but its
+// position circles are hover-only (tooltips) and must stay live — re-enable
+// them (equal specificity to the suppressor above, wins on source order).
+html .room-gate-page .position-strip .gate-slot { pointer-events: auto; }
.position-strip {
position: absolute;
@@ -438,6 +442,22 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
}
+
+ // Occupant-relative accents (sprint 2026-06-02). Additive over the
+ // .filled/.reserved fill above — border tint only, appended last so
+ // a single-class modifier wins on source order.
+ &.tt-pos-me-current { border-color: rgba(var(--ninUser), 1); }
+ &.tt-pos-me-also { border-color: rgba(var(--ninUser), 0.6); cursor: pointer; }
+ &.tt-pos-bud { border-color: rgba(var(--secUser), 1); }
+
+ // CARTE seat-switch — a full-circle anchor on the viewer's own
+ // non-current seats; ?seat=N loads that seat's view. Sits below any
+ // confirm/release form (later in DOM) so the NVM button still wins.
+ .pos-seat-switch {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ }
}
}
diff --git a/src/templates/apps/gameboard/_partials/_position_tooltip.html b/src/templates/apps/gameboard/_partials/_position_tooltip.html
new file mode 100644
index 0000000..24f7144
--- /dev/null
+++ b/src/templates/apps/gameboard/_partials/_position_tooltip.html
@@ -0,0 +1,14 @@
+{# Position-circle tooltip portal — page-root, position:fixed (escapes any #}
+{# tray overflow / mask-image clip per the portal gotcha). Distinct id from #}
+{# the tray's #id_tooltip_portal so the two hover systems don't collide. #}
+{# position-tooltip.js fills it on .gate-slot mouseenter from the circle's #}
+{# data-tt-* attrs + copies the circle's .tt-pos-* class on so it themes per #}
+{# occupant kind. The .tt-sign stack (seat significator: rank + suit) pins #}
+{# top-right, modeled on the tray sig card. #}
+
+
+
+
+
+
+
diff --git a/src/templates/apps/gameboard/_partials/_table_positions.html b/src/templates/apps/gameboard/_partials/_table_positions.html
index 988e843..c67d6df 100644
--- a/src/templates/apps/gameboard/_partials/_table_positions.html
+++ b/src/templates/apps/gameboard/_partials/_table_positions.html
@@ -1,9 +1,22 @@
+{% load lyric_extras %}
+{# Gate-position circles (1–6). Each carries a `.tt-pos-*` state class + #}
+{# `data-tt-*` payload (sprint 2026-06-02) that position-tooltip.js reads on #}
+{# hover to fill #id_position_tooltip_portal — @handle / title / seat-sig / #}
+{# bud shoptalk / #tokens / seat-clock expiry. NB: the `.tt-pos-*` class is #}
+{# appended AFTER `role-assigned` so the `gate-slot filled role-assigned` #}
+{# substring (RoleSelectRenderingTest) stays intact, and `class` stays first #}
+{# (before data-slot) for the class-attr regex IT. #}
{% for pos in gate_positions %}
-
+
{{ pos.slot.slot_number }}
{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}
+ {% if pos.is_me_also %}
+ {# CARTE: the viewer's own seat they aren't currently acting #}
+ {# as — a switch href loads that seat's view (?seat=N). #}
+
+ {% endif %}
{% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
@@ -168,5 +171,6 @@
+
{% endblock scripts %}
diff --git a/src/templates/apps/gameboard/room_gate.html b/src/templates/apps/gameboard/room_gate.html
index bd82cc8..7640a6b 100644
--- a/src/templates/apps/gameboard/room_gate.html
+++ b/src/templates/apps/gameboard/room_gate.html
@@ -80,6 +80,12 @@
+ {# Position circles + their hover tooltips — the gate-view rendered no #}
+ {# circles before this sprint (the headline gap). Reuses the shared #}
+ {# _table_positions partial fed by the merged _gate_context. #}
+ {% include "apps/gameboard/_partials/_table_positions.html" %}
+ {% include "apps/gameboard/_partials/_position_tooltip.html" %}
+
{# NVM nav-backs one step to the table hex (not out to /gameboard/). #}
{% url 'epic:room' room.id as nvm_url %}
{% include "apps/gameboard/_partials/_room_gear.html" with nvm_url=nvm_url %}
@@ -90,6 +96,7 @@
{% block scripts %}
+