PICK SEA reversal axis: server-side roll + preview + deposited slot — TDD

- new apps/epic/utils.STACK_REVERSAL_PROBABILITY (=0.25) + stack_reversal_probability(user, room) helper; single source of truth across game phases & one-line swap point for forthcoming per-user-profile config
- sea_deck view rolls each card's `reversed` axis at fetch time using the helper, attaches to card JSON; matches the eager shuffle pattern (whole deal determined at phase start)
- room_view + sea_partial pass `stack_reversal_pct` into context for the new <p class="sea-reversal-hint">25% reversals</p> hint above the SPREAD combobox (italic, 0.7rem, 0.55 opacity)
- SeaDeal.openStage applies .stage-card--reversed + .is-reversed to stat block when card.reversed → preview lands face-reversed w. REVERSAL keywords
- _fillSlot adds .sea-card-slot--reversed → slot itself rotates 180° (bg + border + content stack flips, not just inner chars upside-down in place); .sea-pos-cross overrides to 270° to compose w. its existing 90°
- _fillSlot adds .sea-card-slot--rank-long when corner_rank.length ≥ 5 (XVIII / XXIII / XXVIII / XXXIII / XXXVIII / XLIII / XLVIII) → SCSS scaleX(0.7) + letter-spacing -0.05em squeezes horizontally w.o changing font-size

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-01 00:11:40 -04:00
parent da57106d7a
commit c264b6e3ee
5 changed files with 89 additions and 2 deletions

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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)