Files
python-tdd/src/apps/epic/utils.py
Disco DeDisco 15025b4188 A.7-polish my_sea slot image-rendering (server-saved + mid-draw JS) + non-polarized single-deck-stack collapse — TDD. End-of-session wrap-up 2026-05-25 PM. Three changes covering my_sea.html surfaces that weren't in A.5/A.7's central-sig-only scope: (1) saved-hand slot rendering (_my_sea_slot.html server-rendered partial fired when a draw is resumed via refresh); (2) mid-draw slot fill (sea.js's _fillSlot writes slot.innerHTML on each card-deposit click; previously rendered corner-rank + suit-icon only, NOW renders <img> when card.image_url is non-empty); (3) deck-stack collapse for non-polarized decks (Minchiate today) — the bottom-right of my_sea.html showed two side-by-side GRAVITY + LEVITY stacks regardless of equipped-deck polarization; for non-polarized decks polarity has no meaning so the dual layout misleads. **Critical lock**: collapse is my_sea-ONLY. room.html keeps the dual stacks since multiple gamers contribute (each might bring a different polarization). Server-side template branches {% if request.user.equipped_deck.is_polarized %} to pick dual vs. single rendering; the --single stack carries the actual deck back-image via <img class="sea-stack-face-img"> (object-fit: cover) when has_card_images=True. Sub-changes: card_dict() in apps/epic/utils.py now includes image_url + arcana_key fields so the picker grid's JSON payload carries the data the JS fill-handler needs (single source of truth shared w. the gameroom sea_deck endpoint — apps/gameboard/views.py's saved_by_position dict gets the parallel additions for server-rendered saved hand). SCSS: extended the shared image-mode rule's comma-list selector in _card-deck.scss to include .sea-card-slot.sea-card-slot--image so the contour stroke + depth shadow apply to both saved + mid-draw slots from a single rule definition. Also added .sea-deck-stack--single .sea-stack-face block w. neutral --priUser/--terUser palette (vs. gravity's --quiUser/--quaUser + levity's --terUser/--ninUser) + the corresponding hover/active glow rule positioned AFTER the $_sea-shadow SCSS variable definition at line 1808 (initial draft hit a compile error: Undefined variable: "$_sea-shadow" because the hover rule was placed before the variable was defined; SCSS variables are scope/order-dependent). JS: _fillSlot in sea.js branches on card.image_url — when non-empty, write <img class="sig-stage-card-img"> + add .sea-card-slot--image marker + data-arcana-key attr; otherwise legacy corner-rank + suit-icon. innerHTML alt-attribute properly escapes " to &quot; so card names w. quotes (none today, but defensive) don't break HTML. Existing JS that activates a clicked stack (_activeStack flow + _showOk / _hideOk) works unchanged w. the single-stack variant since the selector .sea-deck-stack matches all variants regardless of polarity suffix; the isLevity = stack.classList.contains('sea-deck-stack--levity') check at the deposit moment returns false for --single → defaults to gravity polarity assignment, which is fine for non-polarized decks (polarity field has no card-content effect). Memory updated: project_image_based_deck_face_rendering.md now lists A.0-A.7 done + this polish + room.html (A.8) as the sole remaining surface for tomorrow. The 6-surface scope sheet shows A.8 as the last red box; everything else green. Tests: 1306/1306 IT+UT total green (73s). No new ITs in this commit — the saved-slot render touch was an extension of existing saved_by_position view context shape (covered by existing slot-render tests' implicit invariance); the JS change is hard to test via Django ITs (would need Jasmine spec or FT, deferred); the deck-stack collapse is a template branch (visual; user verified live in browser this session). Tomorrow: A.8 room.html image-rendering (multi-user surface via Channels WebSocket payload + same template branch pattern; keep dual gravity/levity stacks per user spec)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:16:53 -04:00

167 lines
6.8 KiB
Python

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 (DRAW 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 card_dict(card, reversal_prob=STACK_REVERSAL_PROBABILITY):
"""Canonical serialization of a TarotCard → JSON payload.
Single source of truth for the gameroom `sea_deck` endpoint AND the
`_my_sea_deck_data` helper on /gameboard/my-sea/. Iter 4b of the My
Sea roadmap extracted this from two near-identical copies that had
drifted on the Major Arcana polarity-split keys (cards 19-21 + 48-49)
— keeping the contract in one place prevents the same drift recurring.
The `reversed` flag is rolled fresh each call via `random.random()`
against `reversal_prob`. Callers that need a deterministic flag (e.g.
re-rendering a previously-saved hand) should NOT use this helper —
look up the card and serialize manually with the persisted flag.
"""
import random as _random
return {
'id': card.id,
'name': card.name,
'arcana': card.arcana,
'suit': card.suit,
'number': card.number,
'corner_rank': card.corner_rank,
'suit_icon': card.suit_icon,
'name_group': card.name_group,
'name_title': card.name_title,
'levity_qualifier': card.levity_qualifier,
'gravity_qualifier': card.gravity_qualifier,
'reversal_qualifier': card.reversal_qualifier,
# Pattern B / B' marker — cards 16-18 set this True so the reversal
# face renders the name swap WITHOUT a polarity qualifier appended.
# See `TarotCard.applet_face` docstring for the full pattern table.
'reversal_drops_qualifier': card.reversal_drops_qualifier,
# Polarity-split full-title overrides (cards 48-49 + trumps 19-21)
'levity_emanation': card.levity_emanation,
'gravity_emanation': card.gravity_emanation,
'levity_reversal': card.levity_reversal,
'gravity_reversal': card.gravity_reversal,
'italic_word': card.italic_word,
'keywords_upright': card.keywords_upright,
'keywords_reversed': card.keywords_reversed,
'energies': card.energies,
'operations': card.operations,
'reversed': _random.random() < reversal_prob,
# Sprint A.7-polish — image-mode payload for the my-sea picker's
# JS-driven `_fillSlot` (which writes slot.innerHTML on draw). Empty
# strings for legacy text-only decks (Earthman, RWS); non-empty when
# `deck.has_card_images=True` (Minchiate today). JS branches on
# `image_url` to render an <img> instead of corner-rank + suit-icon
# so the mid-draw slot fill matches the server-rendered saved-hand
# `_my_sea_slot.html` partial branch.
'image_url': card.image_url,
'arcana_key': card.arcana,
}
# Element key → in-game capacitor name (mirrors ELEMENT_INFO in sky-wheel.js).
# Used by the SKY_SAVED provenance event to render prose like
# "yields them a unique Ardor capacity."
ELEMENT_CAPACITOR_NAMES = {
"Fire": "Ardor",
"Stone": "Ossum",
"Time": "Tempo",
"Space": "Nexus",
"Air": "Pneuma",
"Water": "Humor",
}
# Canonical clockwise-ring ordering for tie-break and prose joining.
ELEMENT_ORDER = ["Fire", "Stone", "Time", "Space", "Air", "Water"]
def top_capacitors(elements):
"""Return capacitor names tied for the highest count in `elements`.
`elements` is the chart-data dict whose values are either ints (raw counts)
or {"count": int, ...} enriched dicts. Order follows ELEMENT_ORDER so tied
output is deterministic across runs and matches the wheel's visual order.
"""
if not elements:
return []
def _count(v):
return v.get("count", 0) if isinstance(v, dict) else (v or 0)
counts = {k: _count(v) for k, v in elements.items()}
if not counts or max(counts.values()) <= 0:
return []
top = max(counts.values())
return [
ELEMENT_CAPACITOR_NAMES[k]
for k in ELEMENT_ORDER
if counts.get(k) == top and k in ELEMENT_CAPACITOR_NAMES
]
def _planet_house(degree, cusps):
"""Return 1-based house number for a planet at ecliptic degree.
cusps is the 12-element list from PySwiss where cusps[i] is the start of
house i+1. Handles the wrap-around case where a cusp crosses 0°/360°.
"""
degree = degree % 360
for i in range(12):
start = cusps[i] % 360
end = cusps[(i + 1) % 12] % 360
if start < end:
if start <= degree < end:
return i + 1
else: # wrap-around: e.g. cusp at 350° → next at 10°
if degree >= start or degree < end:
return i + 1
return 1
def _compute_distinctions(planets, houses):
"""Return dict {house_number_str: planet_count} for all 12 houses."""
cusps = houses['cusps']
counts = {str(i): 0 for i in range(1, 13)}
for planet_data in planets.values():
h = _planet_house(planet_data['degree'], cusps)
counts[str(h)] += 1
return counts
def rooms_for_user(user):
"""Return a queryset of rooms the user owns, has a gate slot in, or is invited to."""
return Room.objects.filter(
Q(owner=user) |
Q(gate_slots__gamer=user) |
Q(invites__invitee_email=user.email, invites__status=RoomInvite.PENDING)
).distinct()
def annotate_latest_event(rooms):
"""Attach `latest_event` (GameEvent or None) to each Room in the iterable.
Materialises the queryset to a list. Shared by the My Scrolls (billboard)
+ My Games (gameboard) applet rows so they render the same 3-col
`<title> | <latest event prose> | <ts>` shape from one helper."""
rooms = list(rooms)
for r in rooms:
r.latest_event = r.events.order_by("-timestamp").first()
return rooms