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

1310 lines
50 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,
)
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
from apps.epic.utils import _compute_distinctions, _planet_house, card_dict, 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
fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename **CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:18:32 -04:00
# `equipped_deck_id` gates the JS role-select guard (role-select.js L165).
# Falls back to ANY of the user's seats in this room w. deck_variant set
# — covers the CARTE multi-seat return path where the first role-pick
# cleared `user.equipped_deck` (the deck is committed to seats, but still
# "in play"). Without this, a CARTE user navigating away + back gets
# "Equip card deck before Role select" wrongly fired. See [[sprint-carte-
# role-select-return-may18]] / commit-TBD for the bug trail.
role_select_deck_id = (
user.equipped_deck_id if user.is_authenticated else None
)
if user.is_authenticated and not role_select_deck_id:
seat_w_deck = room.table_seats.filter(
gamer=user, deck_variant__isnull=False,
).order_by("slot_number").first()
if seat_w_deck:
role_select_deck_id = seat_w_deck.deck_variant_id
ctx = {
"card_stack_state": card_stack_state,
fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename **CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:18:32 -04:00
"equipped_deck_id": role_select_deck_id,
"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"
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
# Reversal-rate hint label under DRAW 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
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
drafts AND confirmed rows. The in-room CAST SKY DEL targets this so SAVE
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
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):
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
"""Shuffled deck lists (levity + gravity halves) for DRAW 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)
available = list(
TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
)
_random.shuffle(available)
mid = len(available) // 2
return JsonResponse({
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
'levity': [card_dict(c, reversal_prob) for c in available[:mid]],
'gravity': [card_dict(c, reversal_prob) 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)