diff --git a/src/apps/epic/static/apps/epic/position-tooltip.js b/src/apps/epic/static/apps/epic/position-tooltip.js index 7bbdb61..d8c2c86 100644 --- a/src/apps/epic/static/apps/epic/position-tooltip.js +++ b/src/apps/epic/static/apps/epic/position-tooltip.js @@ -84,6 +84,8 @@ var PositionTooltip = (function () { _set(".tt-title", title); _set(".tt-description", circle.dataset.ttDescription); _set(".tt-shoptalk", circle.dataset.ttShoptalk); + var tokens = circle.dataset.ttTokens; + _set(".tt-tokens", tokens ? tokens + (tokens === "1" ? " token" : " tokens") : ""); _set(".tt-expiry", _fmtExpiry(circle.dataset.ttExpiry)); var sign = _portal.querySelector(".tt-sign"); @@ -112,6 +114,11 @@ var PositionTooltip = (function () { circle._posTooltipBound = true; circle.addEventListener("mouseenter", function () { if (_activeTrig === circle) return; + // Clear any prior tooltip BEFORE showing the next — otherwise + // an A→B move accumulates B's .tt-pos-* classes onto the portal + // (A's never get stripped), and an A→empty move leaves A's + // tooltip stranded (since _show early-returns for empty slots). + _hide(); _show(circle); }); }); diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 6f5a3c8..b3262a3 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -702,6 +702,14 @@ class PositionTooltipRenderTest(TestCase): slot2 = _circle_tag(self._gate_content(), 2) self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2) + def test_no_occupant_email_anywhere_in_page_source(self): + # The position circles render @handle (at_handle), never the raw login + # email — not in the tooltip payload AND not in the hidden .slot-gamer + # span. Assert on the FULL response, not just the circle's opening tag. + content = self._gate_content() + self.assertNotIn(self.gamers[1].email, content) # g2@test.io + self.assertNotIn(self.viewer.email, content) # disco@test.io + class PositionTooltipCarteRenderTest(TestCase): """CARTE-solo render contract: a single gamer owns all six slots — their @@ -762,6 +770,16 @@ class PositionTooltipCarteRenderTest(TestCase): self.assertIn('data-active-slot="1"', content) self.assertIn('data-state="eligible"', content) + def test_room_gate_renders_circles_but_no_carte_action_forms(self): + # The renewal gate-view shows the circles (for their hover tooltips) + # but is NOT a gather surface — no CARTE drop/release forms. + gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id}) + content = self.client.get(gate_url).content.decode() + self.assertIn("position-strip", content) + self.assertEqual(content.count('class="gate-slot'), 6) + self.assertNotIn("slot-release-btn", content) + self.assertNotIn("drop-token-btn", content) + class PickRolesViewTest(TestCase): def setUp(self): diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 5ec90e6..6aa37d2 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -215,7 +215,10 @@ def _gate_positions(room, user=None, current_slot=None): # 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() - seats_by_slot = {s.slot_number: s for s in room.table_seats.all()} + seats_by_slot = { + s.slot_number: s + for s in room.table_seats.select_related("significator").all() + } authed = getattr(user, "is_authenticated", False) bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set() shoptalk_map = {} @@ -225,8 +228,15 @@ def _gate_positions(room, user=None, current_slot=None): bn.bud_id: bn.shoptalk for bn in BudshipNote.objects.filter(user=user) } + # One CARTE-token-per-gamer map, hoisted out of the loop — a CARTE gamer + # owns ONE token (carrying slots_claimed) but claims many slots, so the + # per-slot lookup was up to 6 identical queries. + carte_claims = { + t.user_id: t.slots_claimed + for t in Token.objects.filter(token_type=Token.CARTE, current_room=room) + } positions = [] - for slot in room.gate_slots.order_by("slot_number"): + for slot in room.gate_slots.select_related("gamer").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 @@ -244,20 +254,16 @@ def _gate_positions(room, user=None, current_slot=None): 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 + tokens = carte_claims.get(gamer.id, 1) + else: + tokens = 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, "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, @@ -514,9 +520,11 @@ def _role_select_context(room, user, seat_param=None): user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None # CARTE seat-switch (?seat=N): a multi-seat owner picks a sig per seat, # so the overlay must reflect the SELECTED seat (its role/polarity/deck), - # not the canonical PC seat. `current_slot` is the ?seat-resolved owned - # slot (falls back to canonical for a one-seat gamer, who never switches). - if user.is_authenticated and current_slot is not None: + # not the canonical PC seat. Gate on an EXPLICIT ?seat — with no param, + # `current_slot` is merely the lowest owned GATE slot, which need not be + # the canonical PC seat (roles aren't slot-ordered); keep the canonical + # seat in that case so every SIG_SELECT surface (incl. my_tray_sig) agrees. + if seat_param and user.is_authenticated and current_slot is not None: _seat_override = room.table_seats.filter( gamer=user, slot_number=current_slot ).first() @@ -665,6 +673,13 @@ def room_gate(request, room_id): "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", + # The renewal gate-view shows the circles for their rich hover tooltips + # ONLY — it is not a gather surface. Zero the CARTE drop/release form + # triggers so _table_positions renders no OK/NVM/PICK ROLES buttons here + # (renewal happens via the modal's own token rails). + "carte_next_slot_number": None, + "carte_nvm_slot_number": None, + "is_last_slot": False, }) return render(request, "apps/gameboard/room_gate.html", ctx) diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index f9c67a5..706f3f1 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -228,8 +228,11 @@ body.page-gameboard { .tt-title, .tt-description, .tt-shoptalk, + .tt-tokens, .tt-expiry { display: block; } + .tt-tokens { font-size: 0.75rem; opacity: 0.65; } + // 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 { diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 32813b8..bd570cf 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -361,8 +361,10 @@ 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; } +// them. The doubled `.room-gate-page` makes this (0,4,1) so it UNAMBIGUOUSLY +// out-specifies the (0,3,1) suppressor above — not a fragile source-order tie +// that a future SCSS reorder could silently flip. [[feedback-scss-import-order-specificity]] +html .room-gate-page.room-gate-page .position-strip .gate-slot { pointer-events: auto; } .position-strip { position: absolute; diff --git a/src/templates/apps/gameboard/_partials/_position_tooltip.html b/src/templates/apps/gameboard/_partials/_position_tooltip.html index 24f7144..ab9e475 100644 --- a/src/templates/apps/gameboard/_partials/_position_tooltip.html +++ b/src/templates/apps/gameboard/_partials/_position_tooltip.html @@ -10,5 +10,6 @@ + diff --git a/src/templates/apps/gameboard/_partials/_table_positions.html b/src/templates/apps/gameboard/_partials/_table_positions.html index c67d6df..a5e723a 100644 --- a/src/templates/apps/gameboard/_partials/_table_positions.html +++ b/src/templates/apps/gameboard/_partials/_table_positions.html @@ -11,7 +11,7 @@