Files
python-tdd/src/apps/epic/views.py

1323 lines
51 KiB
Python
Raw Normal View History

import json
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
import zoneinfo
from datetime import datetime, timedelta
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
import requests as http_requests
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db import transaction
PICK SKY DEL: server purge of seat Character + race guards stop the btn from re-injecting; readonly opacity bump (0.6 → 0.85) — TDD Two related Sky Select bugs the old DEL flow couldn't address. (1) DEL btn lingered after a clear because an in-flight schedulePreview's .then() could resolve AFTER the OK callback ran, calling _ensureDelBtn() against a freshly-cleared wheel-col. (2) Sky data rehydrated on refresh because clicking SAVE SKY confirms a Character row on the seat — the DEL handler only purged localStorage & in-memory state, leaving the durable Character row to drive subsequent renders. Server: new epic.sky_delete(room_id) view (POST → JsonResponse {deleted:True}) deletes every Character on the requesting gamer's seat where retired_at is null — drafts (confirmed_at NULL) and confirmed rows alike. 405 on GET, 403 for outsiders, never touches User.sky_chart_data (Dashsky/My Sky applet's DEL owns that side). JS (_sky_overlay.html): DEL OK callback now (a) bumps a _fetchSeq counter so any in-flight schedulePreview .then()/.catch() short-circuits when its captured seq != current — kills the re-injection race; (b) clearTimeout-s _chartDebounce + _placeDebounce so a typed-just-before-DEL keystroke can't fire schedulePreview after the clear; (c) POSTs to DELETE_URL (overlay.dataset.deleteUrl wired via {% url 'epic:sky_delete' room.id %}) so the seat's Character row is dropped server-side; (d) clears LS + DOM state as before. SCSS: .sky-field input[readonly] opacity 0.6 → 0.85, & dropped the redundant .sky-coords > div input { opacity:0.6 } that was previously winning the cascade by virtue of being declared later. The browser's default ::placeholder is ~0.54, so 0.85 × 0.54 ≈ 0.46 — close to the birth-place placeholder's ~0.54 effective opacity per the user's "appreciably higher tho not opacity 1" target. Values land at 0.85 (clearly readable but still de-emphasized vs. the editable place input). Tests: 4 new ITs in PickSkyRenderingTest cover (a) POST clears confirmed Character, returns JSON {deleted:True}; (b) 405 on GET; (c) 403 for non-seat-owner; (d) User.sky_chart_data untouched by in-room DEL. PickSkyDelTest FT picks up an extra assertion: id_sky_delete_btn must be absent from DOM after OK (the bug-1 regression guard). 55-test sky suite green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:39:07 -04:00
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from apps.drama.models import GameEvent, record
from django.db.models import Case, IntegerField, Value, When
from apps.epic.models import (
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
Character,
GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
TarotCard, TarotDeck,
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
select_token, sig_deck_cards,
)
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
from apps.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability, top_capacitors
from apps.lyric.models import Token
RESERVE_TIMEOUT = timedelta(seconds=60)
def _notify_gate_update(room_id):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'gate_update'},
)
def _notify_turn_changed(room_id):
active_seat = TableSeat.objects.filter(
room_id=room_id, role__isnull=True
).order_by("slot_number").first()
active_slot = active_seat.slot_number if active_seat else None
starter_roles = list(
TableSeat.objects.filter(room_id=room_id, role__isnull=False)
.values_list("role", flat=True)
)
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles},
)
pick_sigs view + cursor polarity groups; game kit gear menu; housekeeping roles_revealed WS event removed; select_role last pick now fires _notify_all_roles_filled() + stays in ROLE_SELECT; new pick_sigs view (POST /room/<uuid>/pick-sigs) transitions ROLE_SELECT→SIG_SELECT + broadcasts sig_select_started; room.html shows .pick-sigs-btn when all 6 roles filled; PICK SIGS btn absent during mid-selection; 11 new/modified ITs in SelectRoleViewTest + RoomViewAllRolesFilledTest + PickSigsViewTest consumer: LEVITY_ROLES {PC/NC/SC} + GRAVITY_ROLES {BC/EC/AC}; connects to per-polarity cursor group (cursors_{id}_levity/gravity); receive_json routes cursor_move to cursor group; new handlers all_roles_filled, sig_select_started, cursor_move; CursorMoveConsumerTest (TransactionTestCase, @tag channels): levity cursor reaches fellow levity player, does not reach gravity player game kit gear menu: #id_game_kit_menu registered in _applets.scss %applet-menu + fixed-position + landscape offset; id_gk_sections_container added to appletContainerIds in applets.js so OK submit dismisses menu; _game_kit_sections.html sections use entry.applet.grid_cols/grid_rows (was hardcoded 6); %applets-grid applied to #id_gk_sections_container (direct parent of sections, not outer wrapper); FT setUp seeds gk-* applets via get_or_create drama test reorg: integrated/test_views.py deleted (no drama views); two test classes moved to epic/tests/integrated/test_views.py + GameEvent import added; drama/tests/unit/test_models.py → drama/tests/integrated/test_models.py; unit/ dir removed login form: position:fixed + vertically centred in base styles across all breakpoints; 24rem width, text-align:center; landscape block reduced to left/right sidebar offsets; alert moved below h2; left-side position indicator slots 3/4/5 column order flipped via CSS data-slot selectors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:33:35 -04:00
def _notify_all_roles_filled(room_id):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
pick_sigs view + cursor polarity groups; game kit gear menu; housekeeping roles_revealed WS event removed; select_role last pick now fires _notify_all_roles_filled() + stays in ROLE_SELECT; new pick_sigs view (POST /room/<uuid>/pick-sigs) transitions ROLE_SELECT→SIG_SELECT + broadcasts sig_select_started; room.html shows .pick-sigs-btn when all 6 roles filled; PICK SIGS btn absent during mid-selection; 11 new/modified ITs in SelectRoleViewTest + RoomViewAllRolesFilledTest + PickSigsViewTest consumer: LEVITY_ROLES {PC/NC/SC} + GRAVITY_ROLES {BC/EC/AC}; connects to per-polarity cursor group (cursors_{id}_levity/gravity); receive_json routes cursor_move to cursor group; new handlers all_roles_filled, sig_select_started, cursor_move; CursorMoveConsumerTest (TransactionTestCase, @tag channels): levity cursor reaches fellow levity player, does not reach gravity player game kit gear menu: #id_game_kit_menu registered in _applets.scss %applet-menu + fixed-position + landscape offset; id_gk_sections_container added to appletContainerIds in applets.js so OK submit dismisses menu; _game_kit_sections.html sections use entry.applet.grid_cols/grid_rows (was hardcoded 6); %applets-grid applied to #id_gk_sections_container (direct parent of sections, not outer wrapper); FT setUp seeds gk-* applets via get_or_create drama test reorg: integrated/test_views.py deleted (no drama views); two test classes moved to epic/tests/integrated/test_views.py + GameEvent import added; drama/tests/unit/test_models.py → drama/tests/integrated/test_models.py; unit/ dir removed login form: position:fixed + vertically centred in base styles across all breakpoints; 24rem width, text-align:center; landscape block reduced to left/right sidebar offsets; alert moved below h2; left-side position indicator slots 3/4/5 column order flipped via CSS data-slot selectors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:33:35 -04:00
{'type': 'all_roles_filled'},
)
def _notify_sig_select_started(room_id):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'sig_select_started'},
)
def _notify_role_select_start(room_id):
slot_order = list(
GateSlot.objects.filter(room_id=room_id, status=GateSlot.FILLED)
.order_by("slot_number")
.values_list("slot_number", flat=True)
)
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'role_select_start', 'slot_order': slot_order},
)
def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type},
)
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
def _notify_sig_reserved(room_id, card_id, role, reserved):
"""Broadcast a sig_reserved event to the matching polarity cursor group."""
polarity = 'levity' if role in _LEVITY_ROLES else 'gravity'
async_to_sync(get_channel_layer().group_send)(
f'cursors_{room_id}_{polarity}',
{'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None,
'role': role, 'reserved': reserved},
)
def _notify_countdown_start(room_id, polarity, *, seconds):
async_to_sync(get_channel_layer().group_send)(
f'cursors_{room_id}_{polarity}',
{'type': 'countdown_start', 'polarity': polarity, 'seconds': seconds},
)
def _notify_countdown_cancel(room_id, polarity, *, seconds_remaining):
async_to_sync(get_channel_layer().group_send)(
f'cursors_{room_id}_{polarity}',
{'type': 'countdown_cancel', 'polarity': polarity, 'seconds_remaining': seconds_remaining},
)
def _notify_polarity_room_done(room_id, polarity):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'polarity_room_done', 'polarity': polarity},
)
def _notify_pick_sky_available(room_id):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'pick_sky_available'},
)
def _notify_sky_confirmed(room_id, seat_role):
async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}',
{'type': 'sky_confirmed', 'seat_role': seat_role},
)
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
_SIG_SEAT_ORDERING = Case(
*[When(role=r, then=Value(i)) for i, r in enumerate(SIG_SEAT_ORDER)],
default=Value(99),
output_field=IntegerField(),
)
def _canonical_user_seat(room, user):
"""Return the user's seat whose role comes first in PC→NC→EC→SC→AC→BC order.
In normal play (one user = one seat) this is equivalent to .first().
For Carte Blanche (one user = all seats) it returns the PC seat, ensuring
sig-select cursor placement is seat-based, not position/slot-based.
"""
return room.table_seats.filter(gamer=user).order_by(_SIG_SEAT_ORDERING).first()
_ROLE_SCRAWL_NAMES = {
"PC": "Player", "NC": "Narrator", "EC": "Economist",
"SC": "Shepherd", "AC": "Alchemist", "BC": "Builder",
}
def _gate_positions(room):
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
# of which role each gamer chose — so use count, not role matching.
assigned_count = room.table_seats.exclude(role__isnull=True).count()
return [
{
"slot": slot,
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
"role_assigned": slot.slot_number <= assigned_count,
}
for slot in room.gate_slots.order_by("slot_number")
]
def _expire_reserved_slots(room):
cutoff = timezone.now() - RESERVE_TIMEOUT
room.gate_slots.filter(
status=GateSlot.RESERVED,
reserved_at__lt=cutoff,
).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None)
def _gate_context(room, user):
_expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number")
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
user_reserved_slot = None
user_filled_slot = None
carte_token = None
carte_slots_claimed = 0
carte_nvm_slot_number = None
carte_next_slot_number = None
if user.is_authenticated:
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
carte_token = user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte_token:
carte_slots_claimed = carte_token.slots_claimed
# NVM shown on the highest-numbered slot this user filled via CARTE
nvm_slot = slots.filter(
debited_token_type=Token.CARTE, gamer=user, status=GateSlot.FILLED
).order_by("-slot_number").first()
if nvm_slot:
carte_nvm_slot_number = nvm_slot.slot_number
# Only the very next empty slot gets an OK button
next_slot = slots.filter(status=GateSlot.EMPTY).order_by("slot_number").first()
if next_slot:
carte_next_slot_number = next_slot.slot_number
carte_active = carte_token is not None
eligible = (
user.is_authenticated
and pending_slot is None
and user_reserved_slot is None
and user_filled_slot is None
and not carte_active
)
token_depleted = eligible and select_token(user) is None
can_drop = eligible and not token_depleted
is_last_slot = (
user_reserved_slot is not None
and slots.filter(status=GateSlot.EMPTY).count() == 0
)
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None or carte_active
return {
"slots": slots,
"pending_slot": pending_slot,
"user_reserved_slot": user_reserved_slot,
"user_filled_slot": user_filled_slot,
"can_drop": can_drop,
"token_depleted": token_depleted,
"is_last_slot": is_last_slot,
"user_can_reject": user_can_reject,
"carte_active": carte_active,
"carte_slots_claimed": carte_slots_claimed,
"carte_nvm_slot_number": carte_nvm_slot_number,
"carte_next_slot_number": carte_next_slot_number,
"gate_positions": _gate_positions(room),
"starter_roles": [],
}
def _role_select_context(room, user):
user_seat = None
active_seat = None
unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
if unassigned.exists():
# Normal path — TableSeats present
active_seat = unassigned.first()
user_seat = None
if user.is_authenticated:
user_seat = room.table_seats.filter(gamer=user, role__isnull=True).order_by("slot_number").first()
if user_seat and user_seat.slot_number == active_seat.slot_number:
card_stack_state = "eligible"
else:
card_stack_state = "ineligible"
else:
# Fallback — no TableSeats yet; use GateSlot drop order
active_slot = room.gate_slots.filter(
status=GateSlot.FILLED
).order_by("slot_number").first()
if active_slot is None:
card_stack_state = None
elif user.is_authenticated and active_slot.gamer == user:
card_stack_state = "eligible"
else:
card_stack_state = "ineligible"
starter_roles = list(
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
)
if len(starter_roles) == 6:
card_stack_state = None
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
assigned_seats = (
sorted(
room.table_seats.filter(gamer=user, role__isnull=False),
key=lambda s: _action_order.get(s.role, 99),
)
if user.is_authenticated else []
)
active_slot = active_seat.slot_number if active_seat else None
_my_role = assigned_seats[0].role if assigned_seats else None
ctx = {
"card_stack_state": card_stack_state,
"equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None,
"starter_roles": starter_roles,
"assigned_seats": assigned_seats,
"my_tray_role": _my_role,
tray cards: shadow, hover-tilt w. focus persistence, role-card tooltip — TDD - _tray.scss: drop-shadow on cell child elements (img → filter:drop-shadow so the silhouette is the shadow caster, div → box-shadow); 7° hover-tilt on .tray-role-card > img (-7°) and .tray-sig-card > .sig-stage-card (+7° via the standalone `rotate` property so the existing -5° baseline transform composes); :focus persists the tilt after click; cursor: pointer - tray.js: set tabIndex=0 on placeCard's role cell + on template-rendered .tray-role-card / .tray-sig-card cells at init() so :focus latches the hover state; clear tabindex in reset() for Jasmine afterEach - TraySpec: 4 new specs covering placeCard tabindex, reset cleanup, init-time tabindex on template-rendered sig & role cards, no-tabindex on bare cells - New tray-tooltip.js (#id_tooltip_portal) — Phase 1 of the apps.tooltips integration: hovering .tray-role-card > img copies its sibling .tt's innerHTML into the page-root portal, anchors above/below the trigger, & clamps to the viewport horizontally; mousemove outside the union of [trigger, portal] rects clears the portal (Game-Kit pattern, no btns) - room.html: #id_tooltip_portal mounted at room-page root (outside tray's overflow:hidden); .tt block rendered inline inside .tray-role-card via {% tooltip %} templatetag w. title=role display name & description="[Placeholder description]" - epic/views.py: my_tray_role_tooltip context dict ({title, description}) keyed off the seated role - TrayTooltipSpec: 8 specs covering portal population, .active class, sibling-.tt fallback, viewport-edge clamp left/right, and union-rect mouseleave - 2 FTs in test_component_tray_tooltip.py: hover role img → portal title=Player + description=Placeholder; mouseleave → portal clears Phase 2 (sig-card tooltip mirroring #id_fan_fyi_panel via a DRY refactor) deferred per plan. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:40:10 -04:00
"my_tray_role_tooltip": (
{
"title": _ROLE_SCRAWL_NAMES.get(_my_role, ""),
"description": "[Placeholder description]",
}
if _my_role else None
),
"my_tray_scrawl_static_path": (
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
if _my_role else None
),
"user_seat": user_seat,
"user_slots": list(
room.table_seats.filter(gamer=user, role__isnull=True)
.order_by("slot_number")
.values_list("slot_number", flat=True)
) if user.is_authenticated else [],
"active_slot": active_slot,
"gate_positions": _gate_positions(room),
"slots": room.gate_slots.order_by("slot_number"),
}
# Tray cell 2: sig card (set once polarity group confirms)
_canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None
if room.table_status == Room.SIG_SELECT:
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
user_role = user_seat.role if user_seat else None
user_polarity = None
if user_role in _LEVITY_ROLES:
user_polarity = 'levity'
elif user_role in _GRAVITY_ROLES:
user_polarity = 'gravity'
user_reservation = SigReservation.objects.filter(
room=room, gamer=user
).first() if user.is_authenticated else None
ctx["user_seat"] = user_seat
ctx["user_polarity"] = user_polarity
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
# Has this gamer's polarity already had significators assigned?
# (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
if user_polarity:
_polarity_roles = _LEVITY_ROLES if user_polarity == 'levity' else _GRAVITY_ROLES
ctx["polarity_done"] = not room.table_seats.filter(
role__in=_polarity_roles, significator__isnull=True
).exists()
else:
ctx["polarity_done"] = False
# Pre-load existing reservations for this polarity so JS can restore
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
if user_polarity:
polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY
reservations = {
str(res.card_id): res.role
for res in room.sig_reservations.filter(polarity=polarity_const)
}
else:
reservations = {}
ctx["sig_reservations_json"] = json.dumps(reservations)
if user_polarity == 'levity':
ctx["sig_cards"] = levity_sig_cards(room, user)
elif user_polarity == 'gravity':
ctx["sig_cards"] = gravity_sig_cards(room, user)
else:
ctx["sig_cards"] = []
if room.table_status == Room.SKY_SELECT:
user_role = _canonical_seat.role if _canonical_seat else None
user_polarity = None
if user_role in _LEVITY_ROLES:
user_polarity = 'levity'
elif user_role in _GRAVITY_ROLES:
user_polarity = 'gravity'
ctx["user_polarity"] = user_polarity
confirmed_char = (
Character.objects.filter(
seat=_canonical_seat,
confirmed_at__isnull=False,
retired_at__isnull=True,
).first()
if _canonical_seat else None
)
sky_confirmed = confirmed_char is not None
ctx["sky_confirmed"] = sky_confirmed
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
if sky_confirmed:
# Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator
return ctx
@login_required
def create_room(request):
if request.method == "POST":
name = request.POST.get("name", "").strip()
if name:
room = Room.objects.create(name=name, owner=request.user)
billscroll: first log entry on a fresh room is a system-authored `Welcome to <name>!` greeting — `epic.create_room` records a `ROOM_CREATED` GameEvent (actor=None) immediately after `Room.objects.create(...)` so every room's scroll opens w. the greeting before any user action; `GameEvent.to_prose` ROOM_CREATED branch swapped from the unused `"opens this room"` legacy prose (verb was declared since the initial drama-app spike but no view recorded it — only test fixtures + AP federation tests touched it) → `f"Welcome to {self.room.name}!"`, deliberately dropping the actor prefix the rest of the verbs lead w. since the welcome is the room's greeting, not a user action; scroll template (`templates/core/_partials/_scroll.html` + `templates/apps/billboard/_partials/_applet-most-recent-scroll.html`) gain an `event.actor`-guarded `<strong>` so the welcome line renders w.o. a leading empty `<strong></strong>` whitespace gap, and the `.drama-event` class branches now read `mine` / `theirs` / `system` (the new `system` slot replaces the prior `else: theirs` fallthrough when actor is None, opening room for system-line styling later w.o. mis-attributing the welcome to a phantom player); RoomCreationViewTest gains test_create_room_records_welcome_event_with_no_actor + test_create_room_welcome_event_renders_welcome_prose; the existing drama.tests.integrated.test_models tests (test_record_without_actor + test_events_ordered_by_timestamp) already exercise actor=None on ROOM_CREATED + the chronological-first position so the rendering contract holds; 928 ITs green — 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:14:01 -04:00
# System-authored welcome (actor=None) — first scroll log
# on every room. Renders via GameEvent.to_prose as
# "Welcome to <name>!" with no actor prefix.
record(room, GameEvent.ROOM_CREATED)
return redirect("epic:gatekeeper", room_id=room.id)
return redirect("/gameboard/")
def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id)
if room.table_status:
return redirect("epic:room", room_id=room_id)
ctx = _gate_context(room, request.user)
ctx["room"] = room
ctx["page_class"] = "page-gameboard"
return render(request, "apps/gameboard/room.html", ctx)
def room_view(request, room_id):
room = Room.objects.get(id=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)
@login_required
def drop_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
token_id = request.POST.get("token_id")
if token_id:
token = request.user.tokens.filter(id=token_id).first()
else:
token = select_token(request.user)
if token is None:
return HttpResponse(status=402)
if token.token_type == Token.CARTE:
# CARTE enters the machine without reserving a slot — all slots
# become individually claimable via .drop-token-btn
if token.current_room_id and token.current_room_id != room.id:
return HttpResponse(status=409)
token.current_room = room
token.save()
if request.user.equipped_trinket_id == token.pk:
request.user.equipped_trinket = None
request.user.save(update_fields=["equipped_trinket"])
request.session["kit_token_id"] = str(token.id)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
status=GateSlot.EMPTY
).order_by("slot_number").first()
if slot:
slot.gamer = request.user
slot.status = GateSlot.RESERVED
slot.reserved_at = timezone.now()
slot.save()
request.session["kit_token_id"] = str(token.id)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def confirm_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot_number = request.POST.get("slot_number")
if slot_number:
# CARTE per-slot fill: directly fill the requested slot
carte = request.user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte:
slot = room.gate_slots.filter(
slot_number=slot_number, status=GateSlot.EMPTY
).first()
if slot:
debit_token(request.user, slot, carte)
# slots_claimed is the high-water mark — advance if beyond current
if int(slot_number) > carte.slots_claimed:
carte.slots_claimed = int(slot_number)
carte.save()
record(room, GameEvent.SLOT_FILLED, actor=request.user,
slot_number=int(slot_number), token_type=Token.CARTE,
token_display=carte.get_token_type_display(),
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
_notify_gate_update(room_id)
else:
slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.RESERVED
).first()
if slot:
token_id = request.session.pop("kit_token_id", None)
token = None
if token_id:
token = request.user.tokens.filter(id=token_id).first()
if not token:
token = select_token(request.user)
if token:
debit_token(request.user, slot, token)
record(room, GameEvent.SLOT_FILLED, actor=request.user,
slot_number=slot.slot_number, token_type=token.token_type,
token_display=token.get_token_type_display(),
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def return_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
# CARTE full return: reset token + all CARTE-debited slots
carte = request.user.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
if carte:
room.gate_slots.filter(
debited_token_type=Token.CARTE, gamer=request.user
).update(
gamer=None, status=GateSlot.EMPTY, filled_at=None,
debited_token_type=None, debited_token_expires_at=None,
)
carte.current_room = None
carte.slots_claimed = 0
carte.save()
request.session.pop("kit_token_id", None)
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
slot = room.gate_slots.filter(
gamer=request.user,
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
).first()
if slot:
if slot.status == GateSlot.FILLED:
if slot.debited_token_type == Token.COIN:
coin = request.user.tokens.filter(
token_type=Token.COIN, current_room=room
).first()
if coin:
coin.current_room = None
coin.next_ready_at = None
coin.save()
elif slot.debited_token_type in (Token.FREE, Token.TITHE):
Token.objects.create(
user=request.user,
token_type=slot.debited_token_type,
expires_at=slot.debited_token_expires_at,
)
request.session.pop("kit_token_id", None)
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.reserved_at = None
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def release_slot(request, room_id):
"""Un-fill a single CARTE-claimed slot without returning the CARTE itself."""
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot_number = request.POST.get("slot_number")
if slot_number:
slot = room.gate_slots.filter(
slot_number=slot_number,
debited_token_type=Token.CARTE,
gamer=request.user,
status=GateSlot.FILLED,
).first()
if slot:
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
if room.gate_status == Room.OPEN:
room.gate_status = Room.GATHERING
room.save()
_notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def select_role(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.table_status != Room.ROLE_SELECT:
return redirect(
"epic:room" if room.table_status else "epic:gatekeeper",
room_id=room_id,
)
role = request.POST.get("role")
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
if not role or role not in valid_roles:
return redirect("epic:room", room_id=room_id)
existing = None
with transaction.atomic():
active_seat = room.table_seats.select_for_update().filter(
role__isnull=True
).order_by("slot_number").first()
if not active_seat or active_seat.gamer != request.user:
return redirect("epic:room", room_id=room_id)
if room.table_seats.filter(role=role).exists():
return HttpResponse(status=409)
active_seat.role = role
existing = room.table_seats.filter(
gamer=request.user, deck_variant__isnull=False,
fix CI FT regressions: deck contribution, ROLE SELECT no-deck guard, sig qualifiers, Carte Blanche multi-slot - test_deck_contribution: get_or_create _equip_earthman + unlocked_decks.add; slot_number=2 on _setup_in_use_deck seat; navigate to /gameboard/ (not gate — game-kit panel absent there); drop #id_kit_card_deck click ({% empty %} placeholder; deck renders in loop when present); use textContent for CSS-hidden tooltip; drop stale .deck-micro-status assertion (now mini-portal) - ROLE SELECT FTs (RoleSelectTest + RoleSelectTrayTest): equip Earthman deck for active-slot user in each test that opens the fan — fixes no-deck JS guard blocking #id_role_select - test_room_sig_select: seed The Nomad/Schizo w. correct Earthman slugs/names + Enlightened/ Engraven qualifiers; grant super-nomad + super-schizo Notes to all gamers so Major Arcana appear in overlay; seed Middle Arcana w. Elevated/Graven qualifiers; rename test methods - test_game_kit: drop stale assertIn("active", text) — availability moved to In-Use mini-portal - Carte Blanche: CB stays equipped after multi-slot deposit (revert drop_token unequip); select_role existing-seat query gains order_by("slot_number") for deterministic primary seat; multi-slot FT: kit bag shows placeholder after first deposit (CB unequipped); cold-feet verifies DON via hover→portal; re-equip via portal DON before re-deposit; new test_carte_in_use_game_kit_shows_room_attribution checks Game Kit tooltip after deposit Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:29:51 -04:00
).exclude(pk=active_seat.pk).order_by("slot_number").first()
active_seat.deck_variant = (
existing.deck_variant if existing else request.user.equipped_deck
)
active_seat.save()
if not existing and request.user.equipped_deck:
request.user.equipped_deck = None
request.user.save(update_fields=["equipped_deck"])
record(room, GameEvent.ROLE_SELECTED, actor=request.user,
role=role, slot_number=active_seat.slot_number,
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
if room.table_seats.filter(role__isnull=True).exists():
_notify_turn_changed(room_id)
else:
pick_sigs view + cursor polarity groups; game kit gear menu; housekeeping roles_revealed WS event removed; select_role last pick now fires _notify_all_roles_filled() + stays in ROLE_SELECT; new pick_sigs view (POST /room/<uuid>/pick-sigs) transitions ROLE_SELECT→SIG_SELECT + broadcasts sig_select_started; room.html shows .pick-sigs-btn when all 6 roles filled; PICK SIGS btn absent during mid-selection; 11 new/modified ITs in SelectRoleViewTest + RoomViewAllRolesFilledTest + PickSigsViewTest consumer: LEVITY_ROLES {PC/NC/SC} + GRAVITY_ROLES {BC/EC/AC}; connects to per-polarity cursor group (cursors_{id}_levity/gravity); receive_json routes cursor_move to cursor group; new handlers all_roles_filled, sig_select_started, cursor_move; CursorMoveConsumerTest (TransactionTestCase, @tag channels): levity cursor reaches fellow levity player, does not reach gravity player game kit gear menu: #id_game_kit_menu registered in _applets.scss %applet-menu + fixed-position + landscape offset; id_gk_sections_container added to appletContainerIds in applets.js so OK submit dismisses menu; _game_kit_sections.html sections use entry.applet.grid_cols/grid_rows (was hardcoded 6); %applets-grid applied to #id_gk_sections_container (direct parent of sections, not outer wrapper); FT setUp seeds gk-* applets via get_or_create drama test reorg: integrated/test_views.py deleted (no drama views); two test classes moved to epic/tests/integrated/test_views.py + GameEvent import added; drama/tests/unit/test_models.py → drama/tests/integrated/test_models.py; unit/ dir removed login form: position:fixed + vertically centred in base styles across all breakpoints; 24rem width, text-align:center; landscape block reduced to left/right sidebar offsets; alert moved below h2; left-side position indicator slots 3/4/5 column order flipped via CSS data-slot selectors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:33:35 -04:00
_notify_all_roles_filled(room_id)
return HttpResponse(status=200)
return redirect("epic:room", room_id=room_id)
@login_required
def pick_sigs(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.table_status == Room.ROLE_SELECT:
room.table_status = Room.SIG_SELECT
room.save()
pick_sigs view + cursor polarity groups; game kit gear menu; housekeeping roles_revealed WS event removed; select_role last pick now fires _notify_all_roles_filled() + stays in ROLE_SELECT; new pick_sigs view (POST /room/<uuid>/pick-sigs) transitions ROLE_SELECT→SIG_SELECT + broadcasts sig_select_started; room.html shows .pick-sigs-btn when all 6 roles filled; PICK SIGS btn absent during mid-selection; 11 new/modified ITs in SelectRoleViewTest + RoomViewAllRolesFilledTest + PickSigsViewTest consumer: LEVITY_ROLES {PC/NC/SC} + GRAVITY_ROLES {BC/EC/AC}; connects to per-polarity cursor group (cursors_{id}_levity/gravity); receive_json routes cursor_move to cursor group; new handlers all_roles_filled, sig_select_started, cursor_move; CursorMoveConsumerTest (TransactionTestCase, @tag channels): levity cursor reaches fellow levity player, does not reach gravity player game kit gear menu: #id_game_kit_menu registered in _applets.scss %applet-menu + fixed-position + landscape offset; id_gk_sections_container added to appletContainerIds in applets.js so OK submit dismisses menu; _game_kit_sections.html sections use entry.applet.grid_cols/grid_rows (was hardcoded 6); %applets-grid applied to #id_gk_sections_container (direct parent of sections, not outer wrapper); FT setUp seeds gk-* applets via get_or_create drama test reorg: integrated/test_views.py deleted (no drama views); two test classes moved to epic/tests/integrated/test_views.py + GameEvent import added; drama/tests/unit/test_models.py → drama/tests/integrated/test_models.py; unit/ dir removed login form: position:fixed + vertically centred in base styles across all breakpoints; 24rem width, text-align:center; landscape block reduced to left/right sidebar offsets; alert moved below h2; left-side position indicator slots 3/4/5 column order flipped via CSS data-slot selectors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:33:35 -04:00
_notify_sig_select_started(room_id)
return redirect("epic:room", room_id=room_id)
@login_required
def pick_roles(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if room.gate_status == Room.OPEN and room.table_status is None:
room.table_status = Room.ROLE_SELECT
room.save()
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
TableSeat.objects.create(
room=room,
gamer=slot.gamer,
slot_number=slot.slot_number,
)
_notify_role_select_start(room_id)
return redirect("epic:room", room_id=room_id)
@login_required
def invite_gamer(request, room_id):
gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD - Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set. - epic.invite_gamer view refactor: • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat). • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username). • RoomInvite stores the resolved User's email (or raw input if unregistered). • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User. • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null. • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before. • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch. - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed. - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE. - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner. - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add). - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable). - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references). - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:59:54 -04:00
"""Gatekeeper invite flow. Backwards-compatible w. the legacy
`invitee_email` form-submit (still POSTs from any old caller); also
serves the new bud-btn slide-out which sends `recipient` (email OR
username) + Accept: application/json. Bud-btn flow:
Resolves recipient via _resolve_recipient (registered User; else None).
Stores RoomInvite using the resolved email (or raw input if unregistered).
Auto-adds inviter recipient to each others' buds (symmetric, per
share_post precedent registered recipients only).
Spawns a Brief w. kind=GAME_INVITE + room=room (post=null).
Returns JSON {brief, recipient_display} when Accept matches; else
redirects to gatekeeper as before."""
if request.method != "POST":
return redirect("epic:gatekeeper", room_id=room_id)
from apps.billboard.models import Brief
from apps.billboard.views import _resolve_recipient
room = Room.objects.get(id=room_id)
is_ajax = "application/json" in request.headers.get("Accept", "")
# New bud-btn field name is `recipient`; legacy form uses `invitee_email`.
raw = (
request.POST.get("recipient")
or request.POST.get("invitee_email")
or ""
).strip()
if not raw:
if is_ajax:
return JsonResponse({"brief": None, "recipient_display": None})
return redirect("epic:gatekeeper", room_id=room_id)
candidate = _resolve_recipient(raw)
is_self = candidate is not None and candidate == request.user
if is_self:
if is_ajax:
return JsonResponse({"brief": None, "recipient_display": None})
return redirect("epic:gatekeeper", room_id=room_id)
# RoomInvite uses the resolved User's email when available (so a
# username-typed invite doesn't store the raw username as if it were
# an email); falls back to the raw input for unregistered addresses.
invitee_email = candidate.email if candidate else raw
bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries `{already_present, recipient_display, recipient_user_id}`; bud-btn.js branches on `already_present` → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads `@<username> is already present`, NVM dismisses, FYI dismisses AND eases in the `.bud-duplicate-flash` class (`color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms`) onto the existing element (`.bud-entry .bud-name` / `.post-recipient[data-user-id=…]` / `.gate-slot.filled[data-user-id=…]`); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — `"0 failures" in result.text` matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. `re.search(r"(?<!\d)0 failures\b", …)` (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:15 -04:00
# Duplicate-invite guard: "already present" = recipient is either
# already seated in the room OR has a (pending/accepted) RoomInvite.
# During gatekeeper phase the visible `.gate-slot.filled` cells are
# GateSlot-driven (TableSeats spin up later at SIG SELECT), so check
# both — GateSlot.FILLED catches the in-phase case, TableSeat catches
# the post-phase case. Seated recipients carry recipient_user_id so
# the client can find the .gate-slot.filled[data-user-id="X"]
# highlight target; pending invitees have no visible slot, so
# recipient_user_id stays null.
already_seated = candidate is not None and (
GateSlot.objects.filter(
room=room, gamer=candidate, status=GateSlot.FILLED,
).exists()
or TableSeat.objects.filter(room=room, gamer=candidate).exists()
gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD - Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set. - epic.invite_gamer view refactor: • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat). • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username). • RoomInvite stores the resolved User's email (or raw input if unregistered). • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User. • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null. • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before. • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch. - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed. - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE. - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner. - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add). - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable). - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references). - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:59:54 -04:00
)
bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries `{already_present, recipient_display, recipient_user_id}`; bud-btn.js branches on `already_present` → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads `@<username> is already present`, NVM dismisses, FYI dismisses AND eases in the `.bud-duplicate-flash` class (`color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms`) onto the existing element (`.bud-entry .bud-name` / `.post-recipient[data-user-id=…]` / `.gate-slot.filled[data-user-id=…]`); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — `"0 failures" in result.text` matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. `re.search(r"(?<!\d)0 failures\b", …)` (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:15 -04:00
already_invited = RoomInvite.objects.filter(
room=room, invitee_email=invitee_email,
).exists()
already_present = already_seated or already_invited
brief = None
if not already_present:
RoomInvite.objects.create(
room=room,
inviter=request.user,
invitee_email=invitee_email,
status=RoomInvite.PENDING,
)
# Buds graph: symmetric auto-add on registered recipients (mirrors
# share_post). Idempotent on M2M; no-op on unregistered recipients.
if candidate is not None:
request.user.buds.add(candidate)
candidate.buds.add(request.user)
# Brief: confirmation banner for the inviter. Brief.post stays
# null; banner FYI navigates to the room's gatekeeper page via
# Brief.room.
brief = Brief.objects.create(
owner=request.user,
post=None,
room=room,
kind=Brief.KIND_GAME_INVITE,
title="Invite sent",
)
recipient_user_id = str(candidate.id) if already_seated else None
gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD - Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set. - epic.invite_gamer view refactor: • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat). • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username). • RoomInvite stores the resolved User's email (or raw input if unregistered). • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User. • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null. • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before. • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch. - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed. - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE. - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner. - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add). - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable). - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references). - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:59:54 -04:00
if is_ajax:
recipient_display = None
if candidate is not None:
recipient_display = candidate.username or candidate.email
return JsonResponse({
bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries `{already_present, recipient_display, recipient_user_id}`; bud-btn.js branches on `already_present` → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads `@<username> is already present`, NVM dismisses, FYI dismisses AND eases in the `.bud-duplicate-flash` class (`color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms`) onto the existing element (`.bud-entry .bud-name` / `.post-recipient[data-user-id=…]` / `.gate-slot.filled[data-user-id=…]`); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — `"0 failures" in result.text` matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. `re.search(r"(?<!\d)0 failures\b", …)` (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:15 -04:00
"brief": brief.to_banner_dict() if brief is not None else None,
gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD - Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set. - epic.invite_gamer view refactor: • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat). • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username). • RoomInvite stores the resolved User's email (or raw input if unregistered). • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User. • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null. • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before. • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch. - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed. - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE. - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner. - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add). - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable). - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references). - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:59:54 -04:00
"recipient_display": recipient_display,
bud panels duplicate-add guard: server-side already_present flag + client-side error Brief w. FYI flash highlight on the existing entry — for each of the three #id_bud_btn panels (My Buds / post-share / gatekeeper-invite), the JSON response from add_bud / share_post / invite_gamer now carries `{already_present, recipient_display, recipient_user_id}`; bud-btn.js branches on `already_present` → calls new Brief.showDuplicateBanner({display_name, target_selector}) instead of the normal onSuccess append; banner title reads `@<username> is already present`, NVM dismisses, FYI dismisses AND eases in the `.bud-duplicate-flash` class (`color: var(--terUser); text-shadow: 0 0 .5em var(--ninUser); transition: 600ms`) onto the existing element (`.bud-entry .bud-name` / `.post-recipient[data-user-id=…]` / `.gate-slot.filled[data-user-id=…]`); gatekeeper "already present" = recipient is either GateSlot.FILLED + gamer OR has TableSeat OR has a pending RoomInvite (highlight target only set when seated — pending invites have no visible slot); .post-recipient chips + .gate-slot.filled cells gain data-user-id so the FYI selector can find them; my_buds.html now loads note.js via the {% block scripts %} pattern (Brief module is required by the duplicate banner path); bonus: latent test_jasmine.py bug fixed — `"0 failures" in result.text` matched "10 failures" / "20 failures" / etc, silently passing up to 99 failed specs; replaced w. `re.search(r"(?<!\d)0 failures\b", …)` (caught my new red specs, would've caught any prior Jasmine regression); 18 new ITs + 10 new Jasmine specs + 3 new FTs (one per panel) — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:15 -04:00
"recipient_user_id": recipient_user_id,
"already_present": already_present,
gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD - Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set. - epic.invite_gamer view refactor: • Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat). • Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username). • RoomInvite stores the resolved User's email (or raw input if unregistered). • Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User. • Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null. • Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before. • Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch. - _gatekeeper.html: gate-invite-panel block (lines 62-71) removed. - new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE. - room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner. - Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add). - test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable). - test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references). - 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:59:54 -04:00
})
return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def delete_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
if request.user == room.owner:
room.delete()
return redirect("/gameboard/")
@login_required
def abandon_room(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
room.gate_slots.filter(gamer=request.user).update(
gamer=None, status="EMPTY", filled_at=None
)
room.invites.filter(
invitee_email=request.user.email,
status=RoomInvite.PENDING
).delete()
return redirect("/gameboard/")
def gate_status(request, room_id):
room = Room.objects.get(id=room_id)
ctx = _gate_context(room, request.user)
ctx["room"] = room
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
@login_required
def sig_reserve(request, room_id):
"""Provisional card hold (OK / NVM) during SIG_SELECT.
POST body: card_id=<uuid>, action=reserve|release
"""
if request.method != "POST":
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
if room.table_status != Room.SIG_SELECT:
return HttpResponse(status=400)
user_seat = _canonical_user_seat(room, request.user)
if not user_seat or not user_seat.role:
return HttpResponse(status=403)
action = request.POST.get("action", "reserve")
if action == "release":
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
released_card_id = existing.card_id if existing else None
if existing and existing.ready:
# Gamer released while ready — treat as an implicit WAIT NVM
prior = room.events.filter(
actor=request.user, verb=GameEvent.SIG_READY
).last()
if prior and not prior.data.get("retracted"):
prior.data["retracted"] = True
prior.save(update_fields=["data"])
record(room, GameEvent.SIG_UNREADY, actor=request.user)
polarity = existing.polarity
all_ready = SigReservation.objects.filter(
room=room, polarity=polarity, ready=True
).count() == 3
if all_ready:
_notify_countdown_cancel(room_id, polarity, seconds_remaining=12)
SigReservation.objects.filter(room=room, gamer=request.user).delete()
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
return HttpResponse(status=200)
# Reserve action
card_id = request.POST.get("card_id")
try:
card = TarotCard.objects.get(pk=card_id)
except TarotCard.DoesNotExist:
return HttpResponse(status=400)
polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
# Block if another gamer in the same polarity already holds this card
if SigReservation.objects.filter(
room=room, card=card, polarity=polarity
).exclude(gamer=request.user).exists():
return HttpResponse(status=409)
# Block if this gamer already holds a *different* card — must NVM first
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
if existing and existing.card != card:
return HttpResponse(status=409)
# Idempotent: already holding the same card
if existing:
return HttpResponse(status=200)
SigReservation.objects.create(
room=room, gamer=request.user, card=card,
seat=user_seat, role=user_seat.role, polarity=polarity,
)
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
return HttpResponse(status=200)
@login_required
def sig_ready(request, room_id):
"""Toggle ready/unready for the polarity-room countdown.
POST body: action=ready|unready [, seconds_remaining=<int>]
"""
if request.method != "POST":
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
if room.table_status != Room.SIG_SELECT:
return HttpResponse(status=400)
user_seat = _canonical_user_seat(room, request.user)
if user_seat is None:
return HttpResponse(status=403)
action = request.POST.get("action", "ready")
reservation = SigReservation.objects.filter(room=room, gamer=request.user).first()
if action == "ready":
if reservation is None:
return HttpResponse(status=400)
if reservation.ready:
return HttpResponse(status=200) # idempotent — already ready, don't re-trigger countdown
reservation.ready = True
reservation.save(update_fields=["ready"])
card = reservation.card
if card:
_qual = card.levity_qualifier if reservation.polarity == SigReservation.LEVITY else card.gravity_qualifier
_card_display = f"{_qual} {card.name_title}" if _qual else card.name_title
else:
_card_display = "a card"
record(room, GameEvent.SIG_READY, actor=request.user,
card_name=_card_display,
corner_rank=card.corner_rank if card else "",
suit_icon=card.suit_icon if card else "")
# Retract the most recent un-retracted SIG_UNREADY (cancellation is now moot)
prior_unready = room.events.filter(
actor=request.user, verb=GameEvent.SIG_UNREADY
).last()
if prior_unready and not prior_unready.data.get("retracted"):
prior_unready.data["retracted"] = True
prior_unready.save(update_fields=["data"])
# Check if all three in this polarity are now ready
polarity = reservation.polarity
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
ready_count = SigReservation.objects.filter(
room=room, polarity=polarity, ready=True
).count()
if ready_count == 3:
from apps.epic.tasks import schedule_polarity_confirm
# Use saved countdown_remaining if a pause was recorded, else 12
saved = SigReservation.objects.filter(
room=room, polarity=polarity
).exclude(countdown_remaining__isnull=True).values_list(
"countdown_remaining", flat=True
).first()
seconds = saved if saved is not None else 12
schedule_polarity_confirm(str(room_id), polarity, seconds)
_notify_countdown_start(room_id, polarity, seconds=seconds)
else: # unready
if reservation is not None:
reservation.ready = False
reservation.save(update_fields=["ready"])
# Mark the most recent un-retracted SIG_READY event for this actor
prior = room.events.filter(
actor=request.user, verb=GameEvent.SIG_READY
).last()
if prior and not prior.data.get("retracted"):
prior.data["retracted"] = True
prior.save(update_fields=["data"])
record(room, GameEvent.SIG_UNREADY, actor=request.user)
polarity = reservation.polarity
# Save remaining seconds on all polarity reservations
try:
seconds_remaining = int(request.POST.get("seconds_remaining", 12))
except (TypeError, ValueError):
seconds_remaining = 12
SigReservation.objects.filter(room=room, polarity=polarity).update(
countdown_remaining=seconds_remaining
)
_notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
from apps.epic.tasks import cancel_polarity_confirm
cancel_polarity_confirm(str(room_id), polarity)
return HttpResponse(status=200)
@login_required
def sig_confirm(request, room_id):
"""Finalise polarity group once the countdown fires.
POST body: polarity=levity|gravity
"""
if request.method != "POST":
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
if room.table_status != Room.SIG_SELECT:
return HttpResponse(status=400)
user_seat = _canonical_user_seat(room, request.user)
if user_seat is None:
return HttpResponse(status=403)
polarity = request.POST.get("polarity", SigReservation.LEVITY)
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
# Idempotency: seats already have significators
if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
return HttpResponse(status=200)
# All three in the polarity group must be ready
ready_count = SigReservation.objects.filter(room=room, polarity=polarity, ready=True).count()
if ready_count < 3:
return HttpResponse(status=400)
# Assign significators from reservations
reservations = list(
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
.select_related('seat', 'card')
)
for res in reservations:
if res.seat:
res.seat.significator = res.card
res.seat.save(update_fields=['significator'])
SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)
_notify_polarity_room_done(room_id, polarity)
# If both polarities are now done, advance to SKY_SELECT
if not room.table_seats.filter(significator__isnull=True).exists():
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
_notify_pick_sky_available(room_id)
return HttpResponse(status=200)
@login_required
def select_sig(request, room_id):
if request.method != "POST":
return redirect("epic:gatekeeper", room_id=room_id)
room = Room.objects.get(id=room_id)
if room.table_status != Room.SIG_SELECT:
return redirect(
"epic:room" if room.table_status else "epic:gatekeeper",
room_id=room_id,
)
active_seat = active_sig_seat(room)
if active_seat is None or active_seat.gamer != request.user:
return HttpResponse(status=403)
card_id = request.POST.get("card_id")
try:
card = TarotCard.objects.get(pk=card_id)
except TarotCard.DoesNotExist:
return HttpResponse(status=400)
sig_card_ids = {c.pk for c in sig_deck_cards(room)}
if card.pk not in sig_card_ids:
return HttpResponse(status=400)
if room.table_seats.filter(significator=card).exists():
return HttpResponse(status=409)
active_seat.significator = card
active_seat.save()
deck_type = request.POST.get('deck_type', 'levity')
_notify_sig_selected(room_id, card.pk, active_seat.role, deck_type)
return HttpResponse(status=200)
@login_required
def tarot_deck(request, room_id):
room = Room.objects.get(id=room_id)
deck_variant = request.user.equipped_deck
deck, _ = TarotDeck.objects.get_or_create(
room=room,
defaults={"deck_variant": deck_variant},
)
return render(request, "apps/gameboard/tarot_deck.html", {
"room": room,
"deck": deck,
"remaining": deck.remaining_count,
})
@login_required
def tarot_deal(request, room_id):
if request.method != "POST":
return redirect("epic:tarot_deck", room_id=room_id)
room = Room.objects.get(id=room_id)
deck = TarotDeck.objects.get(room=room)
drawn = deck.draw(6) # Celtic Cross: 6 cross positions; 4 staff filled via gameplay
positions = [
{
"card": card,
"reversed": is_reversed,
"orientation": "Reversed" if is_reversed else "Upright",
"position": i + 1,
}
for i, (card, is_reversed) in enumerate(drawn)
]
return render(request, "apps/gameboard/tarot_deck.html", {
"room": room,
"deck": deck,
"remaining": deck.remaining_count,
"positions": positions,
})
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
# ── Sky (natal chart) ───────────────────────────────────────────────────────
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
@login_required
def sky_preview(request, room_id):
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
"""Proxy GET to PySwiss /api/chart/ and augment with distinction counts.
Query params:
date YYYY-MM-DD (local birth date)
time HH:MM (local birth time, default 12:00)
tz IANA timezone string (optional; auto-resolved from lat/lon if absent)
lat float
lon float
If tz is absent or blank, calls PySwiss /api/tz/ to resolve it from the
coordinates before converting the local datetime to UTC.
Response includes a 'timezone' key (resolved or supplied) so the client
can back-fill the timezone field after the first wheel render.
No database writes safe for debounced real-time calls.
"""
seat = _canonical_user_seat(Room.objects.get(id=room_id), request.user)
if seat is None:
return HttpResponse(status=403)
date_str = request.GET.get('date')
time_str = request.GET.get('time', '12:00')
tz_str = request.GET.get('tz', '').strip()
lat_str = request.GET.get('lat')
lon_str = request.GET.get('lon')
if not date_str or lat_str is None or lon_str is None:
return HttpResponse(status=400)
try:
lat = float(lat_str)
lon = float(lon_str)
except ValueError:
return HttpResponse(status=400)
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return HttpResponse(status=400)
# Resolve timezone from coordinates if not supplied
if not tz_str:
try:
tz_resp = http_requests.get(
settings.PYSWISS_URL + '/api/tz/',
params={'lat': lat_str, 'lon': lon_str},
timeout=5,
)
tz_resp.raise_for_status()
tz_str = tz_resp.json().get('timezone') or 'UTC'
except Exception:
tz_str = 'UTC'
try:
tz = zoneinfo.ZoneInfo(tz_str)
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
return HttpResponse(status=400)
try:
local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
local_dt = local_dt.replace(tzinfo=tz)
utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
except ValueError:
return HttpResponse(status=400)
try:
resp = http_requests.get(
settings.PYSWISS_URL + '/api/chart/',
params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
timeout=5,
)
resp.raise_for_status()
except Exception:
return HttpResponse(status=502)
data = resp.json()
# PySwiss uses "Earth"; the wheel and SCSS use "Stone".
if 'elements' in data and 'Earth' in data['elements']:
data['elements']['Stone'] = data['elements'].pop('Earth')
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
data['timezone'] = tz_str
return JsonResponse(data)
@login_required
def sky_save(request, room_id):
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
"""Create or update the draft Character for the requesting gamer's seat.
POST body (JSON):
birth_dt ISO 8601 UTC datetime
birth_lat float
birth_lon float
birth_place display string (optional)
house_system single char, default 'O'
chart_data full PySwiss response dict (incl. distinctions)
action 'save' (default) or 'confirm'
On 'confirm': sets confirmed_at, locking the Character.
Returns: {id, confirmed}
"""
if request.method != 'POST':
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
if seat is None:
return HttpResponse(status=403)
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponse(status=400)
# Find or create the active draft (unconfirmed, unretired) for this seat
char = Character.objects.filter(
seat=seat, confirmed_at__isnull=True, retired_at__isnull=True,
).first()
if char is None:
char = Character(seat=seat)
char.birth_dt = body.get('birth_dt')
char.birth_lat = body.get('birth_lat')
char.birth_lon = body.get('birth_lon')
char.birth_place = body.get('birth_place', '')
char.house_system = body.get('house_system', Character.PORPHYRY)
char.chart_data = body.get('chart_data')
char.significator = seat.significator
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
if body.get('action') == 'confirm':
char.confirmed_at = timezone.now()
char.save()
if char.is_confirmed:
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
from apps.drama.models import GameEvent, record
caps = top_capacitors((char.chart_data or {}).get('elements'))
record(
room, GameEvent.SKY_SAVED, actor=request.user,
top_capacitors=caps,
)
_notify_sky_confirmed(room_id, seat.role)
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed})
PICK SKY DEL: server purge of seat Character + race guards stop the btn from re-injecting; readonly opacity bump (0.6 → 0.85) — TDD Two related Sky Select bugs the old DEL flow couldn't address. (1) DEL btn lingered after a clear because an in-flight schedulePreview's .then() could resolve AFTER the OK callback ran, calling _ensureDelBtn() against a freshly-cleared wheel-col. (2) Sky data rehydrated on refresh because clicking SAVE SKY confirms a Character row on the seat — the DEL handler only purged localStorage & in-memory state, leaving the durable Character row to drive subsequent renders. Server: new epic.sky_delete(room_id) view (POST → JsonResponse {deleted:True}) deletes every Character on the requesting gamer's seat where retired_at is null — drafts (confirmed_at NULL) and confirmed rows alike. 405 on GET, 403 for outsiders, never touches User.sky_chart_data (Dashsky/My Sky applet's DEL owns that side). JS (_sky_overlay.html): DEL OK callback now (a) bumps a _fetchSeq counter so any in-flight schedulePreview .then()/.catch() short-circuits when its captured seq != current — kills the re-injection race; (b) clearTimeout-s _chartDebounce + _placeDebounce so a typed-just-before-DEL keystroke can't fire schedulePreview after the clear; (c) POSTs to DELETE_URL (overlay.dataset.deleteUrl wired via {% url 'epic:sky_delete' room.id %}) so the seat's Character row is dropped server-side; (d) clears LS + DOM state as before. SCSS: .sky-field input[readonly] opacity 0.6 → 0.85, & dropped the redundant .sky-coords > div input { opacity:0.6 } that was previously winning the cascade by virtue of being declared later. The browser's default ::placeholder is ~0.54, so 0.85 × 0.54 ≈ 0.46 — close to the birth-place placeholder's ~0.54 effective opacity per the user's "appreciably higher tho not opacity 1" target. Values land at 0.85 (clearly readable but still de-emphasized vs. the editable place input). Tests: 4 new ITs in PickSkyRenderingTest cover (a) POST clears confirmed Character, returns JSON {deleted:True}; (b) 405 on GET; (c) 403 for non-seat-owner; (d) User.sky_chart_data untouched by in-room DEL. PickSkyDelTest FT picks up an extra assertion: id_sky_delete_btn must be absent from DOM after OK (the bug-1 regression guard). 55-test sky suite green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:39:07 -04:00
@login_required
def sky_delete(request, room_id):
"""Purge the requesting gamer's Character on this seat — both unconfirmed
drafts AND confirmed rows. The in-room PICK SKY DEL targets this so SAVE
SKY DEL refresh truly drops the saved sky for the seat. The User
model's sky_chart_data is intentionally untouched (Dashsky / My Sky
applet's DEL handles that separately)."""
if request.method != 'POST':
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
if seat is None:
return HttpResponseForbidden()
Character.objects.filter(seat=seat, retired_at__isnull=True).delete()
return JsonResponse({'deleted': True})
@login_required
def sea_deck(request, room_id):
"""Shuffled deck lists (levity + gravity halves) for PICK SEA draw.
Excludes all Significators already claimed by seated gamers.
Returns {levity: [{id, name, arcana, suit, number, levity_qualifier,
gravity_qualifier}], gravity: [...]}
"""
import random as _random
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
if seat is None:
return HttpResponse(status=403)
deck = seat.deck_variant
if not deck:
return JsonResponse({'levity': [], 'gravity': []})
sig_ids = set(
room.table_seats.exclude(significator__isnull=True)
.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,
'name': c.name,
'arcana': c.arcana,
'suit': c.suit,
'number': c.number,
'corner_rank': c.corner_rank,
'suit_icon': c.suit_icon,
'name_group': c.name_group,
'name_title': c.name_title,
'levity_qualifier': c.levity_qualifier,
'gravity_qualifier': c.gravity_qualifier,
Game Kit fan stage + FLIP/SPIN; sig/sea/fan refactor — TDD - fan modal: stage block w. idle-reveal/careen-out; carousel shifts left so focused card sits left-of-center; SPIN rotates whole card via Element.animate(); FLIP toggles polarity (Levity ↔ Gravity) via perspective rotateY w. mid-flip repaint; SPIN state retained across FLIP; FLIP btn hover-revealed only when focused card or btn is hovered (:has) - mobile breakpoints: --fan-card-w / --fan-card-h / --fan-stage-shift / --fan-carousel-step lifted to CSS vars on .tarot-fan-wrap; portrait ≤ 480px @ 150×230, landscape ≤ 500h @ 150×235; corners + face text/padding scale w. card width - shared StageCard JS module (apps/epic/stage-card.js): fromDataset, populateCard, populateKeywords, buildInfoData, renderFyi — sig/sea/fan all delegate; ~150 lines de-duplicated - shared @mixin stat-block-shared (SCSS) lifts duplicated stat-face / stat-keywords / sig-info rules; @mixin stage-card-polarity unifies sea-stage--levity/--gravity + fan[data-polarity] coloring - model rename: TarotCard.reversal → reversal_qualifier (migration 0014); render-time fallback to current polarity's qualifier when blank - class unification: .sig-info-open / .sea-info-open / .fyi-open → .fyi-open (on stat block); .sig-flip-btn / .sea-spin-btn / .fan-spin-btn → .spin-btn; same for .fyi-btn / .fyi-prev / .fyi-next - custom combobox (apps/epic/combobox.js) replaces native <select> for PICK SEA spread picker — keyboard nav, click-outside-close, aria roles; Firefox/Chrome OS-rendered <option> ignored CSS - Jasmine: FanStageSpec.js w. idle-reveal / population / SPIN / FYI / FLIP specs; sig + sea fixtures + IT view assertions updated for renamed classes - 748 ITs + Jasmine green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:01:52 -04:00
'reversal_qualifier': c.reversal_qualifier,
# Polarity-split full-title overrides (cards 48-49 + trumps 19-21)
'levity_emanation': c.levity_emanation,
'gravity_emanation': c.gravity_emanation,
'levity_reversal': c.levity_reversal,
'gravity_reversal': c.gravity_reversal,
# Word inside any title slot to wrap in <em> at render time
'italic_word': c.italic_word,
'keywords_upright': c.keywords_upright,
'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(
TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
)
_random.shuffle(available)
mid = len(available) // 2
return JsonResponse({
'levity': [_card_dict(c) for c in available[:mid]],
'gravity': [_card_dict(c) for c in available[mid:]],
})
@login_required
def sea_partial(request, room_id):
"""Return the rendered sea overlay partial for in-page injection after sky confirm."""
room = Room.objects.get(id=room_id)
ctx = _role_select_context(room, request.user)
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)