2026-04-21 15:46:30 -04:00
|
|
|
from django.db.models import Q
|
|
|
|
|
|
|
|
|
|
from apps.epic.models import Room, RoomInvite
|
|
|
|
|
|
|
|
|
|
|
2026-05-01 00:11:40 -04:00
|
|
|
# ── Game-wide constants ────────────────────────────────────────────────────
|
|
|
|
|
# Reversal probability applied to any card pulled from a stack, anywhere in
|
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
|
|
|
# the game (DRAW SEA initially; future phases — gameplay draws etc. — will
|
2026-05-01 00:11:40 -04:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
# Element key → in-game capacitor name (mirrors ELEMENT_INFO in sky-wheel.js).
|
SAVE SKY provenance + sky→hex (not sky→sea) transition — TDD
- drama.GameEvent.SKY_SAVED verb + to_prose branch: "X beholds the skyscape of {poss} birth, which yields {obj} a unique {Cap} capacity."; tied highest scores switch "a unique" → "equal", join w. "and" (2-way) or Oxford comma (3+), and pluralize "capacity" → "capacities"; pronouns resolved from actor.pronouns at render time, same machinery as SIG_READY/ROLE_SELECTED
- epic.utils.ELEMENT_CAPACITOR_NAMES + ELEMENT_ORDER + top_capacitors(elements) helper: maps Fire→Ardor Stone→Ossum Time→Tempo Space→Nexus Air→Pneuma Water→Humor; tolerates both flat-int and enriched-dict (`{count, contributors}`) chart_data shapes; returns capacitor names tied for highest count, ordered by canonical wheel ring
- epic.natus_save: on action=confirm, records GameEvent.SKY_SAVED w. top_capacitors=[…] before _notify_sky_confirmed; per-room billscroll AND billboard Most Recent Scroll pick up the new prose
- _natus_overlay.html _onSkyConfirmed: removed sea-partial fetch+inject; now calls closeNatus() + window.location.reload() so the gamer lands on the table hex w. the PICK SKY → PICK SEA btn swap (server-side, driven by sky_confirmed=True), then opts into the sea overlay manually. The auto-launch via 39e12d6 was buried by FTs that were pinning the wrong contract — gamer never had a chance to witness PICK SEA on the hex
- test_room_sea_select.py: three FTs renamed/rewired from auto-launch assertions (sea_overlay_appears_without_page_refresh, natus_overlay_not_visible_after_sky_confirm, sea_open_class_on_html_after_confirm) to (pick_sea_btn_visible_after_sky_confirm, natus_overlay_closed_after_sky_confirm, clicking_pick_sea_btn_opens_sea_overlay) — sea overlay now requires explicit PICK SEA click
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:57:35 -04:00
|
|
|
# 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
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 15:46:30 -04:00
|
|
|
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()
|
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|