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, } # 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 ` | <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