Compare commits

...

7 Commits

Author SHA1 Message Date
Disco DeDisco
5a39746853 position-circle tooltips: adversarial-review fixes — drop email leak, hide-on-hover-transition, surface #tokens, room_gate tooltip-only, N+1 hoist + specificity hardening — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Follow-up to the position-circle tooltips sprint, addressing confirmed findings from a multi-agent adversarial review of the diff:

- Email leak (privacy): the hidden .slot-gamer span rendered the raw login email into DOM source on every filled circle — widened to room_gate this sprint. Now renders {{ gamer|at_handle }}; new IT asserts no occupant email anywhere in the page source.
- Stale hover state (position-tooltip.js): moving circle→circle accumulated .tt-pos-* classes on the portal (prior set never stripped), and circle→empty left the prior tooltip stranded. Now _hide() before _show() on every transition.
- Dead #tokens plumbing: data-tt-tokens was computed + rendered but never displayed. Surfaced as a .tt-tokens line in the portal.
- room_gate gather forms: the merged _gate_context let a CARTE owner drop/release gate slots from the renewal gate-view. Zeroed carte_next/nvm/is_last_slot so it's tooltip-only; new IT asserts no drop/release forms.
- N+1: hoisted the per-CARTE-slot token lookup into one carte_claims map; added select_related(significator) on seats + select_related(gamer) on gate_slots.
- SIG_SELECT seat override now gated on an EXPLICIT ?seat (no-param falls back to the canonical PC seat, not the lowest gate slot, so every SIG_SELECT surface agrees).
- Dropped dead is_self/is_bud dict keys (kept the locals + is_me_also).
- room-gate pointer-events override doubled to .room-gate-page.room-gate-page → (0,4,1), no longer a source-order tie with the (0,3,1) suppressor.

Tests: 11 position-tooltip FTs green (no skips); +2 ITs (no-email-in-source, room_gate-tooltip-only); full suite 1604 green. Deferred (noted in memory): in-UI seat switcher during SIG_SELECT, NVM-between-seats 409, gate_status/sea_partial enrichment split.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:10:00 -04:00
Disco DeDisco
58280c63f5 my-sea spread: correct CROSS reversal direction — nonreversed (Emanation) faces right, Reversal faces left (swap of 5b6a1be)
Follow-up to 5b6a1be: the rotation values were inverted relative to intent. Corrected per user: the upright/Emanation crossing card points top-RIGHT (90°) and the reversed/Reversal card points top-LEFT (270°), 180° apart. The specificity fix from 5b6a1be stays (the reversed rule is chained to (0,3,0) so it wins the cascade over the (0,2,0) base cross rule — the original equal-specificity tie was why every cross card rendered right and reversal never showed). Pure CSS; modal reversal untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:07:37 -04:00
Disco DeDisco
5b6a1be347 my-sea spread: reversed CROSS card finally reads as reversed — upright cross top points left (270°), reversed points right (90°), 180° apart — TDD-adjacent CSS fix
The Celtic-Cross crossing card is rotated to lie landscape. Two equal-specificity rules set its rotation: `.sea-pos-cross .sea-card-slot` (upright) and `.sea-pos-cross .sea-card-slot--reversed` (reversed) — both (0,2,0). The upright rule sits LATER in source, so it won the cascade for reversed cross cards too: every crossing card rendered at 90° (top-right) and a reversed card never indicated reversal in the spread (the modal was fine). The inline comment claiming the reversed rule had 'higher specificity' was simply wrong.

Fix (user-spec: reversed cross top points rightward): keep the reversed card at 90° (top-right) and flip the UPRIGHT to 270° (top-left), so the two read 180° apart. The reversed rule is re-specified as `.sea-pos-cross .sea-card-slot.sea-card-slot--reversed` (0,3,0) so it genuinely out-specifies the base rule and wins regardless of source order. [[feedback-scss-import-order-specificity]]

Pure CSS in _card-deck.scss; covers both live draws (_fillSlot) and saved hands (_my_sea_slot.html), which share the .sea-card-slot--reversed/.sea-pos-cross classes. Modal reversal (.stage-card--reversed, 180°) untouched. No tests pin the rotation degrees. Verified in the compiled output.css cascade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:59:06 -04:00
Disco DeDisco
efbf98ecf2 per-seat sig for CARTE: ?seat targets the seat + a solo polarity group commits the sig on reserve — multi-gamer reserve→ready→countdown→confirm untouched — TDD
Workstream C of the position-circle tooltips sprint (sprint complete).

Per-seat (not per-gamer) significators for a CARTE solo owner, WITHOUT flipping the SigReservation (room,gamer) unique constraint (which the recon confirmed would break 25+ multi-gamer sig tests + the channels flow).

- sig_reserve resolves the active seat from ?seat=N (carried on the reserve URL) when the viewer owns it, else _canonical_user_seat — so the hold/commit targets the SELECTED seat.
- When the polarity group is SOLO-owned (the viewer owns every PC/NC/SC or BC/EC/AC seat — a CARTE table), reserving commits seat.significator immediately: the 3-ready countdown can never complete solo. The committed sig persists through a NVM release (which only deletes the provisional row), so the viewer reserves each seat in turn. Advances to SKY_SELECT once every seat has a sig (mirrors sig_confirm's tail). Strictly gated to the solo case — a multi-gamer polarity group still rides the existing countdown contract untouched.
- _role_select_context SIG_SELECT branch: overrides user_seat by ?seat (owned) so the overlay reflects the selected seat's role/polarity/deck, and carries ?seat on sig_reserve_url.
- FT refined to the real reserve mechanic (in-card OK btn → .sig-reserved) — the RED spec's .sig-card.reserved / single-body-click was a placeholder; behavior verified is unchanged (per-seat sig persistence).

Tests: CarteSeatSwitchTest.test_carte_saves_a_significator_per_seat FT green (all 4 CarteSeatSwitch + 7 PositionTooltip now green, no skips); 2 solo-CARTE sig ITs (SigReserveSoloCarteTest); full suite 1602 green; channels consumer tests 5 green (WS broadcast intact); multi-gamer SigReserveViewTest/sig_ready/sig_confirm untouched.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:41:51 -04:00
Disco DeDisco
d190b37149 room ?seat=N seat-switch: CARTE gamer previews an owned seat's card-stack — data-active-slot + ineligible .fa-ban when it's not the table's turn — TDD
Workstream B of the position-circle tooltips sprint.

_role_select_context consumes ?seat=N (threaded from room_view): a multi-seat (CARTE) gamer previews a specific owned seat. The card-stack's active slot becomes that seat; it stays 'eligible' only when the previewed seat is also the table's current turn (lowest unassigned seat), else it renders the ineligible .fa-ban. Strictly additive — a one-seat gamer never passes ?seat, and an unowned/garbage param is ignored, so the normal role-select flow (RoleSelectRenderingTest) is untouched.

Tests: CarteSeatSwitchTest.test_switching_seat_loads_that_seats_role_view FT green; 2 new render ITs (seat-param-previews + no-seat-keeps-canonical); RoleSelectRenderingTest still green.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:29:08 -04:00
Disco DeDisco
30246cc94a position circles: rich .tt-pos-* hover tooltips on the gate-view + table circles — @handle/title/seat-sig/shoptalk/#tokens/expiry portal + CARTE me-also ?seat switch href — TDD
Workstream A of the position-circle tooltips sprint (green; B/C ride @skip-ped).

The numbered gate-position circles (1-6) gain rich hover tooltips mirroring the My Buds bud tooltip on every surface — and now render on room_gate.html (the GATE VIEW), which showed no circles before (the headline gap).

- _gate_positions(room, user, current_slot): per-circle .tt-pos-* state class (empty / gamer / gamer+bud / me-current / me-also) + data-tt-* payload (@handle via at_handle NOT email, title, seat significator rank/suit, bud shoptalk, deposited #tokens [CARTE slots_claimed else 1], seat-clock cost_current_until expiry). _viewer_current_slot resolves the viewer's acting seat (?seat override or canonical) to split me-current vs me-also.
- room_gate view merges _gate_context so _table_positions renders there; room_view threads ?seat into _role_select_context.
- _table_positions.html: .tt-pos-* appended AFTER role-assigned (keeps the 'gate-slot filled role-assigned' substring + class-before-data-slot regex intact for RoleSelectRenderingTest), data-tt-* attrs, me-also ?seat switch anchor.
- #id_position_tooltip_portal (page-root, position:fixed) + position-tooltip.js (hover/clamp/union-hide modeled on tray-tooltip.js); .tt-sign rank+suit stack; .tt-pos-* circle accents; room-gate pointer-events re-enable.

Tests: 7 PositionTooltipTest + 2 CarteSeatSwitchTest (tokens, me-also href) FTs green; 8 fast render-level ITs (PositionTooltip{,Carte}RenderTest); full suite 1598 green.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:13:09 -04:00
Disco DeDisco
19471662ff position-circle tooltips: red FT spec (skipped) — gate-position circles get rich .tt-pos-* tooltips + CARTE seat-switch + per-seat SIG — TDD
Outer-loop FTs authored today; implementation lands tomorrow. Both
classes are @skip-ped so the red spec rides into the repo without
breaking the FT CI stage (we just rescued that pipeline); tomorrow's
work removes the skip per-method as each behavior goes green.

Spec encoded (user-spec 2026-06-01):
- gate-position circles (1–6) gain rich hover tooltips mirroring the My
  Buds bud tooltip, on EVERY surface — initial gatekeeper, above the hex,
  AND the new GATE VIEW gate-view (room_gate.html renders no circles
  today: the headline red)
- tooltip: @handle (.tt-title), title (.tt-description), NO email, a
  top-right .tt-sign stack of the SEAT significator (TableSeat.
  significator — per-seat, user-decided), bud shoptalk when the occupant
  is a bud, # tokens deposited (CARTE slots_claimed else 1), .tt-expiry
  (GateSlot.cost_current_until)
- state classes: .tt-pos-empty / .tt-pos-gamer / .tt-pos-gamer.tt-pos-bud
  / .tt-pos-me-current / .tt-pos-me-also (renamed from -me-other per
  user). .tt-pos-me-also carries a ?seat=<n> switch href to load that
  seat's view (preview pos-4 ROLE state w. the .fa-ban atop the deck, or
  SAVE SIG per seat during Sig Select)
- per-seat SIG: today SigReservation is per-(room,gamer) — the FT pins
  per-SEAT sig so a CARTE gamer picks a different sig per seat (tomorrow's
  green = SigReservation rework)

FTs: PositionTooltipTest (8 — circle render on gate-view, me-current /
gamer / bud+shoptalk / no-email / tokens+expiry / seat-sign / hover-
portal) + CarteSeatSwitchTest (4 — me-also switch href, carte token
count, ?seat= loads seat ROLE view, per-seat sig). game_room bucket.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:04:25 -04:00
11 changed files with 985 additions and 19 deletions

View File

@@ -0,0 +1,167 @@
// ── position-tooltip.js ───────────────────────────────────────────────────────
//
// Hover-triggered rich tooltips on the gate-position circles (16), portalled
// to #id_position_tooltip_portal (page-root, position:fixed) so they escape
// the tray's overflow / any mask-image clip. Mirrors tray-tooltip.js's
// hover + viewport-clamp + document-mousemove union-hide shape, but reads its
// content from each circle's data-tt-* attrs (filled server-side) rather than
// a sibling .tt.
//
// On .gate-slot mouseenter:
// • skip empty circles (no data-tt-title)
// • copy the circle's .tt-pos-* state class onto the portal (themes per
// occupant kind: empty / gamer / bud / me-current / me-also)
// • fill .tt-title (@handle) / .tt-description (title) / .tt-shoptalk /
// .tt-expiry (seat clock) + the top-right .tt-sign stack (seat
// significator rank + suit icon; hidden when the seat has no sig yet)
//
// The @handle/title/etc. are computed server-side (at_handle, NOT email) so
// no email leaks into the portal.
var PositionTooltip = (function () {
var _portal = null;
var _activeTrig = null;
var _posClasses = []; // .tt-pos-* classes copied onto the portal
var _onDocMove = null;
function _inRect(x, y, r) {
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
function _set(sel, val) {
var el = _portal.querySelector(sel);
if (el) el.textContent = val || "";
}
function _hide() {
if (!_portal) return;
_portal.classList.remove("active");
if (_posClasses.length) {
_portal.classList.remove.apply(_portal.classList, _posClasses);
_posClasses = [];
}
_portal.style.display = "none";
_activeTrig = null;
}
function _fmtExpiry(iso) {
if (!iso) return "";
var d = new Date(iso);
if (isNaN(d.getTime())) return "";
return "seat held to " + d.toLocaleDateString();
}
// Clamp the fixed portal to the viewport, centred under (or over) the
// trigger — same shape as tray-tooltip.js _position. The circles sit at
// the top of the page so the tooltip renders BELOW them.
function _position(triggerEl) {
var rect = triggerEl.getBoundingClientRect();
var halfW = _portal.offsetWidth / 2;
var rawLeft = rect.left + rect.width / 2;
var minLeft = halfW + 8;
var maxLeft = window.innerWidth - halfW - 8;
_portal.style.left = Math.round(Math.max(minLeft, Math.min(rawLeft, maxLeft))) + "px";
var cy = rect.top + rect.height / 2;
if (cy < window.innerHeight / 2) {
_portal.style.top = Math.round(rect.bottom) + "px";
_portal.style.transform = "translate(-50%, 0.5rem)";
} else {
_portal.style.top = Math.round(rect.top) + "px";
_portal.style.transform = "translate(-50%, calc(-100% - 0.5rem))";
}
}
function _show(circle) {
if (!_portal) return;
var title = circle.dataset.ttTitle;
if (!title) return; // empty slot — no occupant tooltip
var classes = circle.className.match(/tt-pos-[\w-]+/g) || [];
_posClasses = classes;
if (classes.length) _portal.classList.add.apply(_portal.classList, classes);
_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");
if (sign) {
var rank = circle.dataset.ttSignRank;
if (rank) {
var rankEl = sign.querySelector(".fan-corner-rank");
if (rankEl) rankEl.textContent = rank;
var icon = sign.querySelector("i");
if (icon) icon.className = "fa-solid tt-sign-suit " + (circle.dataset.ttSignSuit || "");
sign.style.display = "";
} else {
sign.style.display = "none";
}
}
_portal.classList.add("active");
_portal.style.display = "block";
_activeTrig = circle;
_position(circle);
}
function _bind() {
document.querySelectorAll(".position-strip .gate-slot").forEach(function (circle) {
if (circle._posTooltipBound) return;
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);
});
});
}
function init() {
_portal = document.getElementById("id_position_tooltip_portal");
if (!_portal) return;
_portal.style.display = "none";
_bind();
_onDocMove = function (e) {
if (!_portal.classList.contains("active") || !_activeTrig) return;
var rects = [
_activeTrig.getBoundingClientRect(),
_portal.getBoundingClientRect(),
];
var union = {
left: Math.min.apply(null, rects.map(function (r) { return r.left; })),
top: Math.min.apply(null, rects.map(function (r) { return r.top; })),
right: Math.max.apply(null, rects.map(function (r) { return r.right; })),
bottom: Math.max.apply(null, rects.map(function (r) { return r.bottom; })),
};
if (!_inRect(e.clientX, e.clientY, union)) _hide();
};
document.addEventListener("mousemove", _onDocMove);
}
function reset() {
if (_onDocMove) document.removeEventListener("mousemove", _onDocMove);
_onDocMove = null;
_hide();
_portal = null;
document.querySelectorAll(".position-strip .gate-slot").forEach(function (c) {
delete c._posTooltipBound;
});
}
return { init: init, reset: reset };
})();
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", function () {
PositionTooltip.init();
});
}

View File

@@ -598,6 +598,189 @@ class RoleSelectRenderingTest(TestCase):
self.assertNotIn("fa-circle-check", nc_seat_chunk)
def _circle_start(content, slot_number):
"""Index of the gate-slot circle's opening `<div` for the given slot.
Scopes to `.gate-slot` (room.html also renders `.table-seat` data-slot=N
elements first, so a bare data-slot search would hit the seat, not the
circle)."""
needle = f'data-slot="{slot_number}"'
pos = 0
while True:
idx = content.index('<div class="gate-slot', pos)
end = content.index(">", idx)
if needle in content[idx:end]:
return idx
pos = end
def _circle_tag(content, slot_number):
"""Return the opening `<div ...>` 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)
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
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)
def test_seat_param_previews_that_seat_in_card_stack(self):
# Un-assigned owned seats → ?seat=4 previews seat 4, which is not the
# table's current turn (slot 1) → card-stack ineligible (.fa-ban).
for n in range(1, 7):
TableSeat.objects.create(room=self.room, gamer=self.viewer, slot_number=n)
content = self.client.get(self.room_url + "?seat=4").content.decode()
self.assertIn('data-active-slot="4"', content)
self.assertIn('data-state="ineligible"', content)
def test_no_seat_param_keeps_canonical_active_slot(self):
for n in range(1, 7):
TableSeat.objects.create(room=self.room, gamer=self.viewer, slot_number=n)
content = self.client.get(self.room_url).content.decode()
# Default: the table's turn is slot 1, and the viewer owns it → eligible.
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):
self.founder = User.objects.create(email="founder@test.io")
@@ -1137,6 +1320,65 @@ def _full_sig_setUp(test_case, role_order=None):
return room, gamers, earthman, card_in_deck
class SigReserveSoloCarteTest(TestCase):
"""CARTE solo: one gamer owns every seat, so each polarity group is
solo-owned — reserving commits the sig to the active (?seat) seat
immediately (no 3-gamer countdown can ever complete), and the sig is
per-seat, not per-gamer. The fast IT counterpart to
CarteSeatSwitchTest.test_carte_saves_a_significator_per_seat."""
def setUp(self):
from apps.epic.models import DeckVariant
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
self.viewer = User.objects.create(email="disco@test.io", username="disco")
self.viewer.equipped_deck = self.earthman
self.viewer.save(update_fields=["equipped_deck"])
self.room = Room.objects.create(name="Carte Sig", owner=self.viewer)
for i, role in enumerate(SIG_SEAT_ORDER, start=1):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = self.viewer
slot.status = GateSlot.FILLED
slot.save()
TableSeat.objects.create(
room=self.room, gamer=self.viewer, slot_number=i, role=role,
role_revealed=True, deck_variant=self.earthman,
)
self.room.gate_status = Room.OPEN
self.room.table_status = Room.SIG_SELECT
self.room.save()
self.client.force_login(self.viewer)
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
# Two distinct levity court cards (PC + NC are both levity).
self.card_a = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11)
self.card_b = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12)
def test_solo_reserve_commits_significator_to_active_seat(self):
self.client.post(self.url + "?seat=1",
data={"card_id": self.card_a.id, "action": "reserve"})
seat1 = TableSeat.objects.get(room=self.room, slot_number=1)
self.assertEqual(seat1.significator_id, self.card_a.id)
def test_solo_sig_is_per_seat_not_per_gamer(self):
# Commit seat 1, NVM (frees the per-gamer row; the committed sig
# persists through release), then commit a different card on seat 2.
self.client.post(self.url + "?seat=1",
data={"card_id": self.card_a.id, "action": "reserve"})
self.client.post(self.url,
data={"card_id": self.card_a.id, "action": "release"})
self.client.post(self.url + "?seat=2",
data={"card_id": self.card_b.id, "action": "reserve"})
seat1 = TableSeat.objects.get(room=self.room, slot_number=1)
seat2 = TableSeat.objects.get(room=self.room, slot_number=2)
self.assertEqual(seat1.significator_id, self.card_a.id)
self.assertEqual(seat2.significator_id, self.card_b.id)
self.assertNotEqual(seat1.significator_id, seat2.significator_id)
class SigSelectRenderingTest(TestCase):
"""Gate view at SIG_SELECT renders the Significator deck."""

View File

@@ -178,19 +178,100 @@ _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.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 = {}
if authed:
from apps.billboard.models import BudshipNote
shoptalk_map = {
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.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
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.
if gamer is not None and slot.debited_token_type == Token.CARTE:
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,
}
for slot in room.gate_slots.order_by("slot_number")
]
"state_class": state_class,
"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 +331,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 +387,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")
@@ -350,6 +434,27 @@ def _role_select_context(room, user):
if user.is_authenticated else []
)
active_slot = active_seat.slot_number if active_seat else None
# CARTE seat-switch (?seat=N): a multi-seat gamer previews a specific owned
# seat. The card-stack's active slot becomes that seat; it stays "eligible"
# (role-pickable now) only when the previewed seat is also the table's
# current turn (the lowest unassigned seat), else it shows the ineligible
# .fa-ban. No-op for the normal flow — a one-seat gamer never passes ?seat,
# and an unowned/garbage param is ignored.
if seat_param and user.is_authenticated and card_stack_state is not None:
try:
_seat_n = int(seat_param)
except (TypeError, ValueError):
_seat_n = None
if _seat_n is not None and room.table_seats.filter(
gamer=user, slot_number=_seat_n
).exists():
active_slot = _seat_n
_turn = unassigned.first() if unassigned.exists() else None
card_stack_state = (
"eligible"
if _turn is not None and _turn.slot_number == _seat_n
else "ineligible"
)
_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
@@ -391,7 +496,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
@@ -413,6 +518,18 @@ def _role_select_context(room, user):
if room.table_status == Room.SIG_SELECT:
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. 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()
if _seat_override:
user_seat = _seat_override
user_role = user_seat.role if user_seat else None
user_polarity = None
if user_role in _LEVITY_ROLES:
@@ -426,7 +543,11 @@ def _role_select_context(room, user):
ctx["user_seat"] = user_seat
ctx["user_polarity"] = user_polarity
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
# Carry the active seat on the reserve URL so sig_reserve targets THIS
# seat (per-seat sig), not the canonical one.
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
if user_seat:
ctx["sig_reserve_url"] += f"?seat={user_seat.slot_number}"
# Has this gamer's polarity already had significators assigned?
# (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
@@ -511,7 +632,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 +662,26 @@ 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",
# 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)
@login_required
@@ -1033,6 +1168,20 @@ def sig_reserve(request, room_id):
return HttpResponse(status=400)
user_seat = _canonical_user_seat(room, request.user)
# CARTE per-seat sig: honor a ?seat=N override (carried on the reserve URL)
# so the hold targets the SELECTED owned seat, not the canonical PC one.
seat_param = request.GET.get("seat")
if seat_param:
try:
_seat_n = int(seat_param)
except (TypeError, ValueError):
_seat_n = None
if _seat_n is not None:
_override = room.table_seats.filter(
gamer=request.user, slot_number=_seat_n
).first()
if _override:
user_seat = _override
if not user_seat or not user_seat.role:
return HttpResponse(status=403)
@@ -1089,6 +1238,26 @@ def sig_reserve(request, room_id):
seat=user_seat, role=user_seat.role, polarity=polarity,
)
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
# Solo polarity group (CARTE — the viewer owns EVERY seat in this polarity,
# so there is no co-gamer to ready/countdown-sync against). Commit the sig
# to the active seat right away; the 3-ready countdown can never complete
# solo. The provisional row stays (a NVM frees it for the next seat, and
# seat.significator persists through release). Strictly gated to the solo
# case so the multi-gamer reserve→ready→countdown→confirm contract — and
# its channels tests — are untouched. [[project-position-circle-tooltips]]
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
solo_group = not room.table_seats.filter(
role__in=polarity_roles
).exclude(gamer=request.user).exists()
if solo_group:
user_seat.significator = card
user_seat.save(update_fields=["significator"])
# Solo player has committed every seat's sig → advance to SKY_SELECT
# (mirrors sig_confirm's tail; no countdown ever fires solo).
if not room.table_seats.filter(significator__isnull=True).exists():
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
_notify_pick_sky_available(room_id)
return HttpResponse(status=200)

View File

@@ -0,0 +1,278 @@
"""Position-circle tooltip FTs — RED spec authored 2026-06-01, implemented
tomorrow ([[sprint-position-circle-tooltips]]).
The numbered gate-position circles (16) gain rich hover tooltips mirroring
the My Buds bud tooltip, on EVERY surface they appear: the initial gatekeeper,
above the table hex during role/sig select, AND the new GATE VIEW gate-view
(`room_gate.html`), which today renders no circles at all.
Per-circle tooltip content:
• `.tt-title` = the occupant's @handle (NO email field)
• `.tt-description` = the occupant's active title
• top-right `.tt-sign` stack = the occupant's SEAT significator (rank + suit
icon — `TableSeat.significator`, per user-spec 2026-06-01), pinned like
`.tt-price`
• the bud's shoptalk (`BudshipNote.shoptalk`) when the occupant is a bud
• the number of tokens deposited (CARTE `slots_claimed`, else 1)
• `.tt-expiry` of the deposit (`GateSlot.cost_current_until`)
State class on the circle:
• `.tt-pos-empty` — empty slot
• `.tt-pos-gamer` — another gamer
• `.tt-pos-gamer.tt-pos-bud` — another gamer who is also a bud (+ shoptalk)
• `.tt-pos-me-current` — the viewer's own currently-occupied position
• `.tt-pos-me-also` — the viewer's own position they don't occupy now
(CARTE multi-seat). Also carries a `?seat=<n>`
switch href to load that seat's view, so a
CARTE gamer can preview pos-4's ROLE view
(`.fa-ban` atop the deck) or SAVE SIG per seat
during Sig Select.
Written RED — the feature lands tomorrow. FT bucket: game_room.
Both classes are `@skip`-ped so this red spec rides into the repo WITHOUT
breaking CI (the FT stage runs `functional_tests`). Tomorrow's implementation
removes the skip per-method as each behavior goes green.
"""
from unittest import skip
from django.utils import timezone
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .room_page import _assign_all_roles, _equip_earthman_deck, _fill_room_via_orm
from apps.billboard.models import BudshipNote
from apps.epic.models import GateSlot, Room, TableSeat, TarotCard
from apps.lyric.models import Token, User
_RED = ("RED spec — position-circle tooltips land 2026-06-02 "
"([[project-position-circle-tooltips]]); remove the skip per-method "
"as each behavior goes green.")
def _gate_view_url(self, room):
return self.live_server_url + f"/gameboard/room/{room.id}/gate/view/"
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
circles today)."""
def setUp(self):
super().setUp()
# Viewer (slot 1) — give a username so @handle is stable, not email-derived.
self.viewer = User.objects.create(email="disco@test.io", username="disco")
_equip_earthman_deck(self.viewer)
self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
self.gamers = _fill_room_via_orm(
self.room,
["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,
)
self.room.table_status = Room.ROLE_SELECT # gate-view reachable mid-game
self.room.gate_status = Room.OPEN
self.room.save()
def _open_gate_view(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.get(_gate_view_url(self, self.room))
return self.wait_for(
lambda: self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
)
def test_gate_view_renders_six_position_circles(self):
# room_gate.html renders NO circles today — this is the headline red.
circles = self._open_gate_view()
self.assertEqual(len(circles), 6)
def test_own_occupied_seat_is_pos_me_current(self):
self._open_gate_view()
me = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='1']")
self.assertIn("tt-pos-me-current", me.get_attribute("class"))
def test_other_gamer_circle_is_pos_gamer_with_handle_title_no_email(self):
self._open_gate_view()
other = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']")
cls = other.get_attribute("class")
self.assertIn("tt-pos-gamer", cls)
self.assertNotIn("tt-pos-me", cls)
# Tooltip data rides on the circle (the My Buds data-tt-* pattern).
self.assertIn("@", other.get_attribute("data-tt-title")) # @handle
self.assertTrue(other.get_attribute("data-tt-description")) # title
# No email anywhere in the tooltip payload (user-spec).
self.assertNotIn("@test.io", other.get_attribute("data-tt-title"))
self.assertIsNone(other.get_attribute("data-tt-email"))
def test_bud_occupant_circle_is_pos_bud_and_carries_shoptalk(self):
# Make slot-2's occupant a bud of the viewer, with shoptalk.
amigo = self.gamers[1]
self.viewer.buds.add(amigo)
BudshipNote.objects.create(
user=self.viewer, bud=amigo, shoptalk="met at the deli",
)
self._open_gate_view()
circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']")
cls = circle.get_attribute("class")
self.assertIn("tt-pos-gamer", cls)
self.assertIn("tt-pos-bud", cls)
self.assertEqual(circle.get_attribute("data-tt-shoptalk"), "met at the deli")
def test_deposit_count_and_expiry_present(self):
self._open_gate_view()
circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']")
# 1 token deposited (non-CARTE) + a real expiry (cost_current_until).
self.assertEqual(circle.get_attribute("data-tt-tokens"), "1")
self.assertTrue(circle.get_attribute("data-tt-expiry"))
def test_seat_significator_shows_in_sign_stack(self):
# Assign a significator to seat 2 and assert its rank rides the tooltip.
_assign_all_roles(self.room) # creates TableSeats + advances to SIG_SELECT
seat2 = TableSeat.objects.get(room=self.room, slot_number=2)
sig = TarotCard.objects.filter(
deck_variant=self.viewer.equipped_deck, arcana="MIDDLE",
).first()
seat2.significator = sig
seat2.save(update_fields=["significator"])
self.room.table_status = Room.ROLE_SELECT # gate-view circles visible
self.room.save()
self._open_gate_view()
circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']")
self.assertEqual(
circle.get_attribute("data-tt-sign-rank"), str(sig.corner_rank))
def test_hover_populates_position_tooltip_portal(self):
# The portal mirrors the My Buds portal: hover a circle → #id_position_
# tooltip_portal fills + shows the occupant's @handle, NOT their email.
self._open_gate_view()
circle = self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='2']")
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('mouseenter', {bubbles:true}))",
circle,
)
portal = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_position_tooltip_portal")
)
self.assertIn("active", portal.get_attribute("class"))
title = portal.find_element(By.CSS_SELECTOR, ".tt-title").text
self.assertTrue(title.startswith("@"))
self.assertNotIn("@test.io", portal.text)
class CarteSeatSwitchTest(FunctionalTest):
"""A CARTE gamer occupies multiple positions. Their non-current owned
circles read `.tt-pos-me-also` and carry a `?seat=<n>` switch href that
loads that seat's view — to preview pos-N's ROLE-select state (the `.fa-ban`
atop the deck) or to SAVE SIG per seat during Sig Select."""
def setUp(self):
super().setUp()
self.viewer = User.objects.create(email="disco@test.io", username="disco")
_equip_earthman_deck(self.viewer)
self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
# CARTE: the viewer owns ALL six slots.
_fill_room_via_orm(
self.room, ["disco@test.io"] * 6, # get_or_create → same viewer in all
)
self.room.gate_slots.update(
gamer=self.viewer, status=GateSlot.FILLED,
filled_at=timezone.now(), debited_token_type=Token.CARTE,
)
carte = Token.objects.create(
user=self.viewer, token_type=Token.CARTE,
current_room=self.room, slots_claimed=6,
)
self.carte = carte
self.room.gate_status = Room.OPEN
self.room.save()
def test_own_other_seat_is_me_also_with_switch_href(self):
self.create_pre_authenticated_session("disco@test.io")
# During ROLE_SELECT the viewer "acts as" their lowest seat; the others
# are their own seats they don't currently occupy → me-also + switch.
_assign_all_roles(self.room)
self.room.table_status = Room.ROLE_SELECT
# un-assign roles so the card-stack/.fa-ban preview is live
self.room.table_seats.update(role=None, role_revealed=False)
self.room.save()
self.browser.get(self.live_server_url + f"/gameboard/room/{self.room.id}/")
also = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".gate-slot[data-slot='4'].tt-pos-me-also")
)
href = (also.get_attribute("href")
or also.find_element(By.CSS_SELECTOR, "a").get_attribute("href"))
self.assertIn("seat=4", href)
def test_tokens_deposited_reflects_carte_slots_claimed(self):
self.create_pre_authenticated_session("disco@test.io")
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.browser.get(self.live_server_url + f"/gameboard/room/{self.room.id}/gate/view/")
circle = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='1']")
)
self.assertEqual(circle.get_attribute("data-tt-tokens"), "6")
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).
self.create_pre_authenticated_session("disco@test.io")
_assign_all_roles(self.room)
self.room.table_status = Room.ROLE_SELECT
self.room.table_seats.update(role=None, role_revealed=False)
self.room.save()
self.browser.get(
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=4")
self.wait_for(
lambda: self.assertIn(
"seat=4", self.browser.current_url))
# Seat 4 is not the active turn → card-stack ineligible w/ the fa-ban.
stack = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack"))
self.assertEqual(stack.get_attribute("data-active-slot"), "4")
stack.find_element(By.CSS_SELECTOR, ".fa-ban")
def test_carte_saves_a_significator_per_seat(self):
# Sig Select: the viewer reserves a sig on PC (seat 1) — a solo-owned
# polarity group commits it to THAT seat immediately (no 3-gamer
# countdown). Switching to seat 2 shows its own deck, proving per-seat
# (not per-gamer) sig.
self.create_pre_authenticated_session("disco@test.io")
_assign_all_roles(self.room) # roles + advance to SIG_SELECT
self.room.refresh_from_db()
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
# Seat 1 (PC) — reserve its sig via the in-card OK button.
self.browser.get(
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=1")
card1 = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_sig_deck [data-card-id]"))
ok = card1.find_element(By.CSS_SELECTOR, ".sig-ok-btn")
self.browser.execute_script("arguments[0].click()", ok)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card.sig-reserved"))
# Switch to seat 2 — its sig pick is independent of seat 1's.
self.browser.get(
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=2")
deck2 = self.wait_for(
lambda: self.browser.find_elements(
By.CSS_SELECTOR, "#id_sig_deck [data-card-id]"))
self.assertTrue(deck2)
# Each seat ends up with its own significator (per-seat, not shared).
seat1 = TableSeat.objects.get(room=self.room, slot_number=1)
seat2 = TableSeat.objects.get(room=self.room, slot_number=2)
self.assertIsNotNone(seat1.significator_id)
self.assertNotEqual(seat1.significator_id, seat2.significator_id)

View File

@@ -1676,9 +1676,15 @@ $sea-card-h: 6.5rem;
// not just each character upside-down in place.
.sea-card-slot--reversed { transform: rotate(180deg); }
// Cross-position adds 90° already; reversed cross combines to 270°. Higher
// specificity than the .sea-pos-cross .sea-card-slot rule so it wins.
.sea-pos-cross .sea-card-slot--reversed { transform: rotate(270deg); }
// Reversed (Reversal) crossing card: top toward the LEFT (270° CW) — 180°
// from the upright cross (90°, top-right) so the reversal is visually
// unmistakable in the spread (the modal indicates it separately). MUST
// out-specify the `.sea-pos-cross .sea-card-slot` rule above — chaining
// `.sea-card-slot.sea-card-slot--reversed` makes this (0,3,0) vs that (0,2,0);
// the prior `.sea-pos-cross .sea-card-slot--reversed` was only (0,2,0) — EQUAL
// — so the later upright rule won the cascade and reversed cross never read as
// reversed. [[feedback-scss-import-order-specificity]]
.sea-pos-cross .sea-card-slot.sea-card-slot--reversed { transform: rotate(270deg); }
// Long Roman numerals (≥ 5 chars: XVIII, XXIII, XXVIII, XXXIII, XXXVIII,
// XLIII, XLVIII) — squeeze horizontally via scaleX so they fit the slot
@@ -1783,6 +1789,12 @@ $_sea-card-glow: 0 0 0.5rem 0.5rem rgba(var(--ninUser), 0.3), 0 0 0.4rem rgba(0,
.sea-pos-label { opacity: 0.6; }
}
// Upright (Emanation) crossing card lies across with its top toward the
// RIGHT (90° CW); the reversed (Reversal) card below reads 180° opposite —
// top toward the LEFT — instead of looking identical. (Bug: the reversed rule
// lost an equal-specificity cascade tie, so every cross card pointed right and
// reversal was never indicated. user-spec: nonreversed faces right, reversed
// faces left.)
.sea-pos-cross .sea-card-slot { transform: rotate(90deg); }
// Sig card in center slot — compact rank + icon display; tilted CCW so Cover slot peeks through

View File

@@ -215,6 +215,43 @@ 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-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 {
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;

View File

@@ -359,6 +359,12 @@ 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. 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;
@@ -438,6 +444,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%;
}
}
}

View File

@@ -0,0 +1,15 @@
{# 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. #}
<div id="id_position_tooltip_portal" class="tt">
<span class="tt-sign" style="display:none"><span class="fan-corner-rank"></span><i class="fa-solid tt-sign-suit"></i></span>
<span class="tt-title"></span>
<span class="tt-description"></span>
<span class="tt-shoptalk"></span>
<span class="tt-tokens"></span>
<span class="tt-expiry"></span>
</div>

View File

@@ -1,9 +1,22 @@
{% load lyric_extras %}
{# Gate-position circles (16). 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. #}
<div class="position-strip">
{% for pos in gate_positions %}
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}"
data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}"{% endif %}>
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}{% if pos.state_class %} {{ pos.state_class }}{% endif %}"
data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}" data-tt-title="{{ pos.slot.gamer|at_handle }}" data-tt-description="{{ pos.slot.gamer.active_title_display }}" data-tt-shoptalk="{{ pos.shoptalk }}" data-tt-tokens="{{ pos.tokens }}"{% if pos.expiry %} data-tt-expiry="{{ pos.expiry|date:'c' }}"{% endif %}{% if pos.sign_rank %} data-tt-sign-rank="{{ pos.sign_rank }}" data-tt-sign-suit="{{ pos.sign_suit_icon }}"{% endif %}{% endif %}>
<span class="slot-number">{{ pos.slot.slot_number }}</span>
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer|at_handle }}{% else %}empty{% endif %}</span>
{% 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). #}
<a class="pos-seat-switch" href="{% url 'epic:room' room.id %}?seat={{ pos.slot.slot_number }}" aria-label="Switch to seat {{ pos.slot.slot_number }}"></a>
{% endif %}
{% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
{% csrf_token %}

View File

@@ -150,6 +150,9 @@
{% if room.table_status %}
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
{% endif %}
{# Position-circle tooltip portal — rendered whenever the circles can #}
{# (gatekeeper + role-select; the SIG_SELECT phase hides the strip). #}
{% include "apps/gameboard/_partials/_position_tooltip.html" %}
{% include "apps/gameboard/_partials/_room_gear.html" %}
{% include "apps/gameboard/_partials/_burger.html" %}
</div>
@@ -168,5 +171,6 @@
<script src="{% static 'apps/epic/sea.js' %}"></script>
<script src="{% static 'apps/epic/tray.js' %}"></script>
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
{% endblock scripts %}

View File

@@ -80,6 +80,12 @@
</div>
</div>
{# 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 %}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
<script>
{# Status-dots animation identical loop to _gatekeeper.html so the #}
{# "Token(s) Deposited . . . ." / "Please Deposit Token . . . ." status #}