diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js index ec23845..9f00a0a 100644 --- a/src/apps/epic/static/apps/epic/sea.js +++ b/src/apps/epic/static/apps/epic/sea.js @@ -64,6 +64,10 @@ var SeaDeal = (function () { slot.classList.remove('sea-card-slot--empty'); slot.classList.add('sea-card-slot--filled'); slot.classList.add(isLevity ? 'sea-card-slot--levity' : 'sea-card-slot--gravity'); + if (card.reversed) slot.classList.add('sea-card-slot--reversed'); + // Long Roman numerals (XVIII / XXIII / XXVIII / XXXIII / XXXVIII / + // XLIII / XLVIII) need horizontal squeezing to fit the slot — see SCSS. + if ((card.corner_rank || '').length >= 5) slot.classList.add('sea-card-slot--rank-long'); slot.dataset.cardId = String(card.id); slot.dataset.posKey = posSelector; slot.innerHTML = @@ -101,6 +105,13 @@ var SeaDeal = (function () { _viewingPos = posSelector; _seaHand[posSelector] = { card: card, isLevity: isLevity }; _populate(card, isLevity); + // Server pre-rolled the reversal axis at deck-fetch time + // (apps.epic.utils.stack_reversal_probability). Honor it here so the + // card lands face-reversed if rolled. + if (card.reversed) { + statBlock.classList.add('is-reversed'); + stageCard.classList.add('stage-card--reversed'); + } _fillSlot(posSelector, card, isLevity); _showStage(isLevity); } diff --git a/src/apps/epic/utils.py b/src/apps/epic/utils.py index ae55ba1..89b7559 100644 --- a/src/apps/epic/utils.py +++ b/src/apps/epic/utils.py @@ -3,6 +3,28 @@ from django.db.models import Q from apps.epic.models import Room, RoomInvite +# ── Game-wide constants ──────────────────────────────────────────────────── +# Reversal probability applied to any card pulled from a stack, anywhere in +# the game (PICK SEA initially; future phases — gameplay draws etc. — will +# share this single source of truth). Stub for a future per-user profile +# override: callers MUST go through stack_reversal_probability(user, room) +# rather than referencing the constant directly so the user-config hookup is +# a one-line change inside the helper. +STACK_REVERSAL_PROBABILITY = 0.25 + + +def stack_reversal_probability(user=None, room=None): + """Reversal probability for a draw stack in this user's context. + + Current behavior: returns the module default for everyone. Plumbing point + for a forthcoming per-user setting — when that lands, swap the body to + something like `return getattr(user.profile, 'reversal_rate', STACK_REVERSAL_PROBABILITY)` + and every call site picks up the per-user value automatically. + """ + return STACK_REVERSAL_PROBABILITY + + + def _planet_house(degree, cusps): """Return 1-based house number for a planet at ecliptic degree. diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 94e4e23..4602d6d 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -22,7 +22,7 @@ from apps.epic.models import ( active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, select_token, sig_deck_cards, ) -from apps.epic.utils import _compute_distinctions, _planet_house +from apps.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability from apps.lyric.models import Token @@ -402,6 +402,9 @@ def room_view(request, room_id): ctx = _role_select_context(room, request.user) ctx["room"] = room ctx["page_class"] = "page-gameboard" + # Reversal-rate hint label under PICK SEA's SPREAD select — same helper as + # sea_partial so the value tracks any future per-user override automatically. + ctx["stack_reversal_pct"] = int(round(stack_reversal_probability(request.user, room) * 100)) return render(request, "apps/gameboard/room.html", ctx) @@ -1132,6 +1135,11 @@ def sea_deck(request, room_id): .values_list('significator_id', flat=True) ) + # Roll reversal eagerly during the shuffle — the deck order is fully + # determined at phase start, so the reversal axis should be too. Future + # per-user-profile config rides this same helper. + reversal_prob = stack_reversal_probability(request.user, room) + def _card_dict(c): return { 'id': c.id, @@ -1157,6 +1165,8 @@ def sea_deck(request, room_id): 'keywords_reversed': c.keywords_reversed, 'energies': c.energies, 'operations': c.operations, + # Pre-rolled reversal axis — server-deterministic, client just reads + 'reversed': _random.random() < reversal_prob, } available = list( @@ -1178,5 +1188,10 @@ def sea_partial(request, room_id): if not ctx.get('sky_confirmed'): return HttpResponse(status=403) ctx['room'] = room + # Reversal-rate hint label under SPREAD — both the percentage AND the raw + # probability flow from the same helper, so when per-user config lands we + # only swap the helper body and every render picks it up. + _prob = stack_reversal_probability(request.user, room) + ctx['stack_reversal_pct'] = int(round(_prob * 100)) return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx) diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 5ce5fad..7f84935 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -1053,6 +1053,28 @@ $sea-card-h: 6.5rem; border-color: rgba(var(--secUser), 0.6); } +// Reversed — pre-rolled by sea_deck server-side. Rotate the whole slot +// (background + border + content) so the rank/icon stacking order also +// flips (rank-top + icon-bottom upright → icon-top + rank-bottom reversed), +// 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); } + +// Long Roman numerals (≥ 5 chars: XVIII, XXIII, XXVIII, XXXIII, XXXVIII, +// XLIII, XLVIII) — squeeze horizontally via scaleX so they fit the slot +// without dropping font-size (height stays the same). Class added in +// _fillSlot when card.corner_rank.length >= 5. Slot-level reversed rotation +// already carries the rank along, so scaleX is the only inner transform +// regardless of reversal state. +.sea-card-slot--rank-long .fan-corner-rank { + display: inline-block; + transform: scaleX(0.7); + letter-spacing: -0.05em; +} + // Deposited — fully opaque by default; Cover/Cross are semi-transparent .sea-card-slot--visible { opacity: 1; transition: opacity 1s ease, box-shadow 0.15s ease; } @@ -1188,6 +1210,16 @@ $sea-card-h: 6.5rem; label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; } } +// Forthcoming-feature hint between SPREAD label and the combobox; rendered +// value comes from apps.epic.utils.stack_reversal_probability via the view +// context. +.sea-reversal-hint { + font-size: 0.7rem; + opacity: 0.55; + margin: -0.1rem 0 0; + font-style: italic; +} + // Custom combobox replacement for native dropdowns ignore most CSS on Firefox/Chrome (OS-rendered list); this gives full styling control.