2026-04-05 22:01:23 -04:00
|
|
|
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
|
2026-03-14 02:03:44 -04:00
|
|
|
|
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
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
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
|
2026-03-13 00:31:17 -04:00
|
|
|
from django.contrib.auth.decorators import login_required
|
2026-03-21 14:33:06 -04:00
|
|
|
from django.db import transaction
|
2026-05-08 15:39:07 -04:00
|
|
|
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
|
2026-03-13 00:31:17 -04:00
|
|
|
from django.shortcuts import redirect, render
|
2026-03-14 02:03:44 -04:00
|
|
|
from django.utils import timezone
|
2026-03-12 15:05:02 -04:00
|
|
|
|
2026-03-19 15:48:59 -04:00
|
|
|
from apps.drama.models import GameEvent, record
|
2026-04-08 22:53:44 -04:00
|
|
|
from django.db.models import Case, IntegerField, Value, When
|
|
|
|
|
|
2026-03-24 21:07:01 -04:00
|
|
|
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,
|
2026-04-08 22:53:44 -04:00
|
|
|
GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
|
|
|
|
|
TarotCard, TarotDeck,
|
2026-04-05 22:01:23 -04:00
|
|
|
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
|
|
|
|
|
select_token, sig_deck_cards,
|
2026-03-24 21:07:01 -04:00
|
|
|
)
|
SAVE SKY provenance + sky→hex (not sky→sea) transition — TDD
- drama.GameEvent.SKY_SAVED verb + to_prose branch: "X beholds the skyscape of {poss} birth, which yields {obj} a unique {Cap} capacity."; tied highest scores switch "a unique" → "equal", join w. "and" (2-way) or Oxford comma (3+), and pluralize "capacity" → "capacities"; pronouns resolved from actor.pronouns at render time, same machinery as SIG_READY/ROLE_SELECTED
- epic.utils.ELEMENT_CAPACITOR_NAMES + ELEMENT_ORDER + top_capacitors(elements) helper: maps Fire→Ardor Stone→Ossum Time→Tempo Space→Nexus Air→Pneuma Water→Humor; tolerates both flat-int and enriched-dict (`{count, contributors}`) chart_data shapes; returns capacitor names tied for highest count, ordered by canonical wheel ring
- epic.natus_save: on action=confirm, records GameEvent.SKY_SAVED w. top_capacitors=[…] before _notify_sky_confirmed; per-room billscroll AND billboard Most Recent Scroll pick up the new prose
- _natus_overlay.html _onSkyConfirmed: removed sea-partial fetch+inject; now calls closeNatus() + window.location.reload() so the gamer lands on the table hex w. the PICK SKY → PICK SEA btn swap (server-side, driven by sky_confirmed=True), then opts into the sea overlay manually. The auto-launch via 39e12d6 was buried by FTs that were pinning the wrong contract — gamer never had a chance to witness PICK SEA on the hex
- test_room_sea_select.py: three FTs renamed/rewired from auto-launch assertions (sea_overlay_appears_without_page_refresh, natus_overlay_not_visible_after_sky_confirm, sea_open_class_on_html_after_confirm) to (pick_sea_btn_visible_after_sky_confirm, natus_overlay_closed_after_sky_confirm, clicking_pick_sea_btn_opens_sea_overlay) — sea overlay now requires explicit PICK SEA click
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:57:35 -04:00
|
|
|
from apps.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability, top_capacitors
|
2026-03-15 16:08:34 -04:00
|
|
|
from apps.lyric.models import Token
|
2026-03-13 00:31:17 -04:00
|
|
|
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
RESERVE_TIMEOUT = timedelta(seconds=60)
|
|
|
|
|
|
|
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
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
|
2026-03-18 23:14:53 -04:00
|
|
|
starter_roles = list(
|
|
|
|
|
TableSeat.objects.filter(room_id=room_id, role__isnull=False)
|
|
|
|
|
.values_list("role", flat=True)
|
|
|
|
|
)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
async_to_sync(get_channel_layer().group_send)(
|
|
|
|
|
f'room_{room_id}',
|
2026-03-18 23:14:53 -04:00
|
|
|
{'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles},
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-04 14:33:35 -04:00
|
|
|
def _notify_all_roles_filled(room_id):
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
async_to_sync(get_channel_layer().group_send)(
|
|
|
|
|
f'room_{room_id}',
|
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'},
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 11:03:53 -04:00
|
|
|
def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
|
2026-03-25 01:50:06 -04:00
|
|
|
async_to_sync(get_channel_layer().group_send)(
|
|
|
|
|
f'room_{room_id}',
|
2026-03-25 11:03:53 -04:00
|
|
|
{'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type},
|
2026-03-25 01:50:06 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-05 22:01:23 -04:00
|
|
|
_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},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-09 01:17:24 -04:00
|
|
|
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'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 22:16:38 -04:00
|
|
|
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},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 18:31:05 -04:00
|
|
|
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
|
|
|
|
|
2026-04-08 22:53:44 -04:00
|
|
|
_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()
|
|
|
|
|
|
2026-04-07 00:22:04 -04:00
|
|
|
_ROLE_SCRAWL_NAMES = {
|
|
|
|
|
"PC": "Player", "NC": "Narrator", "EC": "Economist",
|
|
|
|
|
"SC": "Shepherd", "AC": "Alchemist", "BC": "Builder",
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 18:31:05 -04:00
|
|
|
|
|
|
|
|
def _gate_positions(room):
|
2026-03-31 00:01:04 -04:00
|
|
|
"""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()
|
2026-03-30 18:31:05 -04:00
|
|
|
return [
|
2026-03-31 00:01:04 -04:00
|
|
|
{
|
|
|
|
|
"slot": slot,
|
|
|
|
|
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
|
|
|
|
|
"role_assigned": slot.slot_number <= assigned_count,
|
|
|
|
|
}
|
2026-03-30 18:31:05 -04:00
|
|
|
for slot in room.gate_slots.order_by("slot_number")
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
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
|
2026-03-16 00:07:52 -04:00
|
|
|
carte_token = None
|
|
|
|
|
carte_slots_claimed = 0
|
|
|
|
|
carte_nvm_slot_number = None
|
2026-03-16 01:04:52 -04:00
|
|
|
carte_next_slot_number = None
|
2026-03-14 02:03:44 -04:00
|
|
|
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()
|
2026-03-16 00:07:52 -04:00
|
|
|
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
|
2026-03-16 01:04:52 -04:00
|
|
|
# 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
|
2026-03-16 00:07:52 -04:00
|
|
|
carte_active = carte_token is not None
|
2026-03-14 22:00:16 -04:00
|
|
|
eligible = (
|
2026-03-14 02:03:44 -04:00
|
|
|
user.is_authenticated
|
|
|
|
|
and pending_slot is None
|
|
|
|
|
and user_reserved_slot is None
|
|
|
|
|
and user_filled_slot is None
|
2026-03-16 00:07:52 -04:00
|
|
|
and not carte_active
|
2026-03-14 02:03:44 -04:00
|
|
|
)
|
2026-03-14 22:00:16 -04:00
|
|
|
token_depleted = eligible and select_token(user) is None
|
|
|
|
|
can_drop = eligible and not token_depleted
|
2026-03-14 02:03:44 -04:00
|
|
|
is_last_slot = (
|
|
|
|
|
user_reserved_slot is not None
|
|
|
|
|
and slots.filter(status=GateSlot.EMPTY).count() == 0
|
|
|
|
|
)
|
2026-03-16 00:07:52 -04:00
|
|
|
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None or carte_active
|
2026-03-14 02:03:44 -04:00
|
|
|
return {
|
|
|
|
|
"slots": slots,
|
|
|
|
|
"pending_slot": pending_slot,
|
|
|
|
|
"user_reserved_slot": user_reserved_slot,
|
|
|
|
|
"user_filled_slot": user_filled_slot,
|
|
|
|
|
"can_drop": can_drop,
|
2026-03-14 22:00:16 -04:00
|
|
|
"token_depleted": token_depleted,
|
2026-03-14 02:03:44 -04:00
|
|
|
"is_last_slot": is_last_slot,
|
|
|
|
|
"user_can_reject": user_can_reject,
|
2026-03-16 00:07:52 -04:00
|
|
|
"carte_active": carte_active,
|
|
|
|
|
"carte_slots_claimed": carte_slots_claimed,
|
|
|
|
|
"carte_nvm_slot_number": carte_nvm_slot_number,
|
2026-03-16 01:04:52 -04:00
|
|
|
"carte_next_slot_number": carte_next_slot_number,
|
2026-03-30 18:31:05 -04:00
|
|
|
"gate_positions": _gate_positions(room),
|
2026-03-31 00:01:04 -04:00
|
|
|
"starter_roles": [],
|
2026-03-14 02:03:44 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
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"
|
2026-03-18 23:14:53 -04:00
|
|
|
starter_roles = list(
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
|
|
|
|
|
)
|
2026-04-05 01:14:31 -04:00
|
|
|
if len(starter_roles) == 6:
|
|
|
|
|
card_stack_state = None
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_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
|
2026-04-07 00:22:04 -04:00
|
|
|
_my_role = assigned_seats[0].role if assigned_seats else None
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
ctx = {
|
|
|
|
|
"card_stack_state": card_stack_state,
|
2026-04-27 23:52:22 -04:00
|
|
|
"equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None,
|
2026-03-18 23:14:53 -04:00
|
|
|
"starter_roles": starter_roles,
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
"assigned_seats": assigned_seats,
|
2026-04-07 00:22:04 -04:00
|
|
|
"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
|
|
|
|
|
),
|
2026-04-07 00:22:04 -04:00
|
|
|
"my_tray_scrawl_static_path": (
|
|
|
|
|
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
|
|
|
|
|
if _my_role else None
|
|
|
|
|
),
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
"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,
|
2026-03-30 18:31:05 -04:00
|
|
|
"gate_positions": _gate_positions(room),
|
2026-03-31 00:01:04 -04:00
|
|
|
"slots": room.gate_slots.order_by("slot_number"),
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
}
|
2026-04-09 01:17:24 -04:00
|
|
|
# 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
|
|
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
if room.table_status == Room.SIG_SELECT:
|
2026-04-08 22:53:44 -04:00
|
|
|
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
2026-04-05 22:01:23 -04:00
|
|
|
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'
|
|
|
|
|
|
2026-04-13 00:34:05 -04:00
|
|
|
user_reservation = SigReservation.objects.filter(
|
|
|
|
|
room=room, gamer=user
|
|
|
|
|
).first() if user.is_authenticated else None
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
ctx["user_seat"] = user_seat
|
2026-04-05 22:01:23 -04:00
|
|
|
ctx["user_polarity"] = user_polarity
|
2026-04-13 00:34:05 -04:00
|
|
|
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
|
2026-04-05 22:01:23 -04:00
|
|
|
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
|
|
|
|
|
2026-04-13 00:34:05 -04:00
|
|
|
# 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
|
|
|
|
|
|
2026-04-05 22:01:23 -04:00
|
|
|
# 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':
|
2026-04-28 01:05:25 -04:00
|
|
|
ctx["sig_cards"] = levity_sig_cards(room, user)
|
2026-04-05 22:01:23 -04:00
|
|
|
elif user_polarity == 'gravity':
|
2026-04-28 01:05:25 -04:00
|
|
|
ctx["sig_cards"] = gravity_sig_cards(room, user)
|
2026-04-05 22:01:23 -04:00
|
|
|
else:
|
|
|
|
|
ctx["sig_cards"] = []
|
2026-04-26 21:30:27 -04:00
|
|
|
|
|
|
|
|
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
|
2026-04-28 21:46:21 -04:00
|
|
|
confirmed_char = (
|
|
|
|
|
Character.objects.filter(
|
2026-04-26 21:30:27 -04:00
|
|
|
seat=_canonical_seat,
|
|
|
|
|
confirmed_at__isnull=False,
|
|
|
|
|
retired_at__isnull=True,
|
2026-04-28 21:46:21 -04:00
|
|
|
).first()
|
|
|
|
|
if _canonical_seat else None
|
2026-04-26 21:30:27 -04:00
|
|
|
)
|
2026-04-28 21:46:21 -04:00
|
|
|
sky_confirmed = confirmed_char is not None
|
2026-04-26 21:30:27 -04:00
|
|
|
ctx["sky_confirmed"] = sky_confirmed
|
2026-04-28 22:16:38 -04:00
|
|
|
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
|
2026-04-28 21:46:21 -04:00
|
|
|
if sky_confirmed:
|
2026-04-29 00:20:55 -04:00
|
|
|
# Fall back to seat.significator for Characters created before the sync was added
|
|
|
|
|
ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator
|
2026-04-26 21:30:27 -04:00
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
return ctx
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
@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)
|
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)
|
2026-03-13 00:31:17 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room.id)
|
2026-03-13 22:51:42 -04:00
|
|
|
return redirect("/gameboard/")
|
2026-03-13 00:31:17 -04:00
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
def gatekeeper(request, room_id):
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
if room.table_status:
|
2026-03-30 18:31:05 -04:00
|
|
|
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)
|
2026-03-14 02:03:44 -04:00
|
|
|
ctx["room"] = room
|
2026-03-25 15:50:57 -04:00
|
|
|
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
|
2026-05-01 00:11:40 -04:00
|
|
|
# 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))
|
2026-03-14 02:03:44 -04:00
|
|
|
return render(request, "apps/gameboard/room.html", ctx)
|
|
|
|
|
|
2026-03-13 17:31:52 -04:00
|
|
|
|
|
|
|
|
@login_required
|
2026-03-14 02:03:44 -04:00
|
|
|
def drop_token(request, room_id):
|
2026-03-13 17:31:52 -04:00
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-15 01:17:09 -04:00
|
|
|
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:
|
2026-03-14 22:00:16 -04:00
|
|
|
return HttpResponse(status=402)
|
2026-03-16 00:07:52 -04:00
|
|
|
if token.token_type == Token.CARTE:
|
|
|
|
|
# CARTE enters the machine without reserving a slot — all slots
|
|
|
|
|
# become individually claimable via .drop-token-btn
|
2026-04-27 23:24:43 -04:00
|
|
|
if token.current_room_id and token.current_room_id != room.id:
|
|
|
|
|
return HttpResponse(status=409)
|
2026-03-16 00:07:52 -04:00
|
|
|
token.current_room = room
|
|
|
|
|
token.save()
|
2026-04-27 23:24:43 -04:00
|
|
|
if request.user.equipped_trinket_id == token.pk:
|
|
|
|
|
request.user.equipped_trinket = None
|
|
|
|
|
request.user.save(update_fields=["equipped_trinket"])
|
2026-03-16 00:07:52 -04:00
|
|
|
request.session["kit_token_id"] = str(token.id)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
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)
|
2026-03-14 02:03:44 -04:00
|
|
|
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()
|
2026-03-15 01:17:09 -04:00
|
|
|
request.session["kit_token_id"] = str(token.id)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-13 17:31:52 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
2026-03-13 18:37:19 -04:00
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
def confirm_token(request, room_id):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
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()
|
2026-03-19 15:48:59 -04:00
|
|
|
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))
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
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)
|
2026-03-19 15:48:59 -04:00
|
|
|
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))
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
2026-03-15 16:08:34 -04:00
|
|
|
def return_token(request, room_id):
|
2026-03-14 02:03:44 -04:00
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
# 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)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
slot = room.gate_slots.filter(
|
|
|
|
|
gamer=request.user,
|
|
|
|
|
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
|
|
|
|
|
).first()
|
|
|
|
|
if slot:
|
2026-03-15 16:08:34 -04:00
|
|
|
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)
|
2026-03-14 02:03:44 -04:00
|
|
|
slot.gamer = None
|
|
|
|
|
slot.status = GateSlot.EMPTY
|
|
|
|
|
slot.reserved_at = None
|
|
|
|
|
slot.filled_at = None
|
2026-03-15 16:08:34 -04:00
|
|
|
slot.debited_token_type = None
|
|
|
|
|
slot.debited_token_expires_at = None
|
2026-03-14 02:03:44 -04:00
|
|
|
slot.save()
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 00:07:52 -04:00
|
|
|
@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()
|
2026-03-16 01:04:52 -04:00
|
|
|
if room.gate_status == Room.OPEN:
|
|
|
|
|
room.gate_status = Room.GATHERING
|
|
|
|
|
room.save()
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
_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:
|
2026-03-30 18:31:05 -04:00
|
|
|
return redirect(
|
|
|
|
|
"epic:room" if room.table_status else "epic:gatekeeper",
|
|
|
|
|
room_id=room_id,
|
|
|
|
|
)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
role = request.POST.get("role")
|
|
|
|
|
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
|
|
|
|
if not role or role not in valid_roles:
|
2026-03-30 18:31:05 -04:00
|
|
|
return redirect("epic:room", room_id=room_id)
|
2026-04-27 23:24:43 -04:00
|
|
|
existing = None
|
2026-03-21 14:33:06 -04:00
|
|
|
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:
|
2026-03-30 18:31:05 -04:00
|
|
|
return redirect("epic:room", room_id=room_id)
|
2026-03-21 14:33:06 -04:00
|
|
|
if room.table_seats.filter(role=role).exists():
|
|
|
|
|
return HttpResponse(status=409)
|
|
|
|
|
active_seat.role = role
|
2026-04-27 23:24:43 -04:00
|
|
|
existing = room.table_seats.filter(
|
|
|
|
|
gamer=request.user, deck_variant__isnull=False,
|
2026-04-28 16:29:51 -04:00
|
|
|
).exclude(pk=active_seat.pk).order_by("slot_number").first()
|
2026-04-27 23:24:43 -04:00
|
|
|
active_seat.deck_variant = (
|
|
|
|
|
existing.deck_variant if existing else request.user.equipped_deck
|
|
|
|
|
)
|
2026-03-21 14:33:06 -04:00
|
|
|
active_seat.save()
|
2026-04-27 23:24:43 -04:00
|
|
|
if not existing and request.user.equipped_deck:
|
|
|
|
|
request.user.equipped_deck = None
|
|
|
|
|
request.user.save(update_fields=["equipped_deck"])
|
2026-03-19 15:48:59 -04:00
|
|
|
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))
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
if room.table_seats.filter(role__isnull=True).exists():
|
|
|
|
|
_notify_turn_changed(room_id)
|
|
|
|
|
else:
|
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:
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
room.table_status = Room.SIG_SELECT
|
|
|
|
|
room.save()
|
2026-04-04 14:33:35 -04:00
|
|
|
_notify_sig_select_started(room_id)
|
2026-03-30 18:31:05 -04:00
|
|
|
return redirect("epic:room", room_id=room_id)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
def pick_roles(request, room_id):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-21 22:22:06 -04:00
|
|
|
if room.gate_status == Room.OPEN and room.table_status is None:
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
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)
|
2026-03-30 18:31:05 -04:00
|
|
|
return redirect("epic:room", room_id=room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
|
|
|
|
|
|
2026-03-13 18:37:19 -04:00
|
|
|
@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
|
|
|
|
|
|
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
|
|
|
)
|
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({
|
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,
|
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
|
|
|
})
|
|
|
|
|
|
2026-03-13 18:37:19 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
2026-03-13 22:51:42 -04:00
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-14 00:10:40 -04:00
|
|
|
@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/")
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-14 00:10:40 -04:00
|
|
|
@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/")
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-13 22:51:42 -04:00
|
|
|
def gate_status(request, room_id):
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
ctx = _gate_context(room, request.user)
|
|
|
|
|
ctx["room"] = room
|
|
|
|
|
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
2026-03-24 21:07:01 -04:00
|
|
|
|
|
|
|
|
|
2026-04-05 22:01:23 -04:00
|
|
|
@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)
|
|
|
|
|
|
2026-04-08 22:53:44 -04:00
|
|
|
user_seat = _canonical_user_seat(room, request.user)
|
2026-04-05 22:01:23 -04:00
|
|
|
if not user_seat or not user_seat.role:
|
|
|
|
|
return HttpResponse(status=403)
|
|
|
|
|
|
|
|
|
|
action = request.POST.get("action", "reserve")
|
|
|
|
|
|
|
|
|
|
if action == "release":
|
2026-04-05 22:32:40 -04:00
|
|
|
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
|
|
|
|
released_card_id = existing.card_id if existing else None
|
2026-04-13 00:34:05 -04:00
|
|
|
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)
|
2026-04-05 22:01:23 -04:00
|
|
|
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
2026-04-05 22:32:40 -04:00
|
|
|
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
|
2026-04-05 22:01:23 -04:00
|
|
|
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,
|
2026-04-08 22:53:44 -04:00
|
|
|
seat=user_seat, role=user_seat.role, polarity=polarity,
|
2026-04-05 22:01:23 -04:00
|
|
|
)
|
|
|
|
|
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
|
|
|
|
|
return HttpResponse(status=200)
|
|
|
|
|
|
|
|
|
|
|
2026-04-09 01:17:24 -04:00
|
|
|
@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)
|
2026-04-13 00:34:05 -04:00
|
|
|
if reservation.ready:
|
|
|
|
|
return HttpResponse(status=200) # idempotent — already ready, don't re-trigger countdown
|
2026-04-09 01:17:24 -04:00
|
|
|
reservation.ready = True
|
|
|
|
|
reservation.save(update_fields=["ready"])
|
2026-04-13 00:34:05 -04:00
|
|
|
card = reservation.card
|
2026-04-27 22:33:42 -04:00
|
|
|
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
|
2026-04-13 00:34:05 -04:00
|
|
|
else:
|
2026-04-27 22:33:42 -04:00
|
|
|
_card_display = "a card"
|
2026-04-13 00:34:05 -04:00
|
|
|
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"])
|
2026-04-09 01:17:24 -04:00
|
|
|
|
|
|
|
|
# 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:
|
2026-04-13 00:34:05 -04:00
|
|
|
from apps.epic.tasks import schedule_polarity_confirm
|
2026-04-09 01:17:24 -04:00
|
|
|
# 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
|
2026-04-13 00:34:05 -04:00
|
|
|
schedule_polarity_confirm(str(room_id), polarity, seconds)
|
2026-04-09 01:17:24 -04:00
|
|
|
_notify_countdown_start(room_id, polarity, seconds=seconds)
|
|
|
|
|
|
|
|
|
|
else: # unready
|
|
|
|
|
if reservation is not None:
|
|
|
|
|
reservation.ready = False
|
|
|
|
|
reservation.save(update_fields=["ready"])
|
2026-04-13 00:34:05 -04:00
|
|
|
# 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)
|
2026-04-09 01:17:24 -04:00
|
|
|
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)
|
2026-04-13 00:34:05 -04:00
|
|
|
from apps.epic.tasks import cancel_polarity_confirm
|
|
|
|
|
cancel_polarity_confirm(str(room_id), polarity)
|
2026-04-09 01:17:24 -04:00
|
|
|
|
|
|
|
|
return HttpResponse(status=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
def sig_confirm(request, room_id):
|
2026-04-13 13:00:16 -04:00
|
|
|
"""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)
|
|
|
|
|
|
2026-04-09 01:17:24 -04:00
|
|
|
return HttpResponse(status=200)
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 01:50:06 -04:00
|
|
|
@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:
|
2026-03-30 18:31:05 -04:00
|
|
|
return redirect(
|
|
|
|
|
"epic:room" if room.table_status else "epic:gatekeeper",
|
|
|
|
|
room_id=room_id,
|
|
|
|
|
)
|
2026-03-25 01:50:06 -04:00
|
|
|
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()
|
2026-03-25 11:03:53 -04:00
|
|
|
deck_type = request.POST.get('deck_type', 'levity')
|
|
|
|
|
_notify_sig_selected(room_id, card.pk, active_seat.role, deck_type)
|
2026-03-25 01:50:06 -04:00
|
|
|
return HttpResponse(status=200)
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 21:07:01 -04:00
|
|
|
@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},
|
|
|
|
|
)
|
2026-03-24 22:25:25 -04:00
|
|
|
return render(request, "apps/gameboard/tarot_deck.html", {
|
2026-03-24 21:07:01 -04:00
|
|
|
"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)
|
|
|
|
|
]
|
2026-03-24 22:25:25 -04:00
|
|
|
return render(request, "apps/gameboard/tarot_deck.html", {
|
2026-03-24 21:07:01 -04:00
|
|
|
"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
|
|
|
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
# ── 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
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
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()
|
2026-04-16 01:57:02 -04:00
|
|
|
# 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
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
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')
|
|
|
|
|
|
2026-04-28 21:46:21 -04:00
|
|
|
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()
|
2026-04-28 22:16:38 -04:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-04-28 22:16:38 -04:00
|
|
|
_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})
|
|
|
|
|
|
2026-04-28 22:16:38 -04:00
|
|
|
|
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
|
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})
|
|
|
|
|
|
|
|
|
|
|
2026-04-28 23:02:49 -04:00
|
|
|
@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.
|
2026-04-28 23:02:49 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-01 00:11:40 -04:00
|
|
|
# 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)
|
|
|
|
|
|
2026-04-28 23:30:07 -04:00
|
|
|
def _card_dict(c):
|
|
|
|
|
return {
|
|
|
|
|
'id': c.id,
|
|
|
|
|
'name': c.name,
|
|
|
|
|
'arcana': c.arcana,
|
|
|
|
|
'suit': c.suit,
|
|
|
|
|
'number': c.number,
|
|
|
|
|
'corner_rank': c.corner_rank,
|
|
|
|
|
'suit_icon': c.suit_icon,
|
PICK SEA Sprint C: sea stage card viewer — FLIP in, SPIN/FYI, deposit/re-expand — TDD
- sea.js: SeaDeal module — openStage() shows big card viewer w. flip-in animation;
SPIN toggles stage-card--reversed; FYI shows energies/operations (Energy/Operation
titles, PRV/NXT nav); backdrop click deposits card to slot; click deposited slot
re-opens stage; resetHand() clears hand on DEL
- sea_deck view: adds name_group/name_title/reversal/keywords_upright/keywords_reversed/
energies/operations to each card dict (full sig-select stage data set)
- _sea_overlay.html: data-sea-user-polarity attr; sea stage HTML (sig-stage-card shell
+ fan-card-face-upright/reversal structure + sea-stat-block w. SPIN/FYI/PRV/NXT);
FLIP click calls SeaDeal.openStage(); _fillPos removed (sea.js handles slot fill);
_reset calls SeaDeal.resetHand()
- room.html: sea.js included alongside sig-select.js
- _card-deck.scss: sea-stage layout (fixed overlay, backdrop, content row); sea-stage-card
w. @keyframes sea-flip-in (3D rotateY perspective); sea-stat-block scoped styles
incl. SPIN/FYI btns, stat faces, sig-info FYI panel
- SeaDealSpec.js: 20 Jasmine specs — openStage, SPIN, FYI, backdrop dismiss, slot re-expand
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:12:06 -04:00
|
|
|
'name_group': c.name_group,
|
|
|
|
|
'name_title': c.name_title,
|
2026-04-28 23:30:07 -04:00
|
|
|
'levity_qualifier': c.levity_qualifier,
|
|
|
|
|
'gravity_qualifier': c.gravity_qualifier,
|
2026-04-30 21:01:52 -04:00
|
|
|
'reversal_qualifier': c.reversal_qualifier,
|
2026-04-30 23:36:35 -04:00
|
|
|
# Polarity-split full-title overrides (cards 48-49 + trumps 19-21)
|
|
|
|
|
'levity_emanation': c.levity_emanation,
|
|
|
|
|
'gravity_emanation': c.gravity_emanation,
|
|
|
|
|
'levity_reversal': c.levity_reversal,
|
|
|
|
|
'gravity_reversal': c.gravity_reversal,
|
|
|
|
|
# Word inside any title slot to wrap in <em> at render time
|
|
|
|
|
'italic_word': c.italic_word,
|
PICK SEA Sprint C: sea stage card viewer — FLIP in, SPIN/FYI, deposit/re-expand — TDD
- sea.js: SeaDeal module — openStage() shows big card viewer w. flip-in animation;
SPIN toggles stage-card--reversed; FYI shows energies/operations (Energy/Operation
titles, PRV/NXT nav); backdrop click deposits card to slot; click deposited slot
re-opens stage; resetHand() clears hand on DEL
- sea_deck view: adds name_group/name_title/reversal/keywords_upright/keywords_reversed/
energies/operations to each card dict (full sig-select stage data set)
- _sea_overlay.html: data-sea-user-polarity attr; sea stage HTML (sig-stage-card shell
+ fan-card-face-upright/reversal structure + sea-stat-block w. SPIN/FYI/PRV/NXT);
FLIP click calls SeaDeal.openStage(); _fillPos removed (sea.js handles slot fill);
_reset calls SeaDeal.resetHand()
- room.html: sea.js included alongside sig-select.js
- _card-deck.scss: sea-stage layout (fixed overlay, backdrop, content row); sea-stage-card
w. @keyframes sea-flip-in (3D rotateY perspective); sea-stat-block scoped styles
incl. SPIN/FYI btns, stat faces, sig-info FYI panel
- SeaDealSpec.js: 20 Jasmine specs — openStage, SPIN, FYI, backdrop dismiss, slot re-expand
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 01:12:06 -04:00
|
|
|
'keywords_upright': c.keywords_upright,
|
|
|
|
|
'keywords_reversed': c.keywords_reversed,
|
|
|
|
|
'energies': c.energies,
|
|
|
|
|
'operations': c.operations,
|
2026-05-01 00:11:40 -04:00
|
|
|
# Pre-rolled reversal axis — server-deterministic, client just reads
|
|
|
|
|
'reversed': _random.random() < reversal_prob,
|
2026-04-28 23:30:07 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-28 23:02:49 -04:00
|
|
|
available = list(
|
2026-04-28 23:30:07 -04:00
|
|
|
TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
|
2026-04-28 23:02:49 -04:00
|
|
|
)
|
|
|
|
|
_random.shuffle(available)
|
|
|
|
|
mid = len(available) // 2
|
2026-04-28 23:30:07 -04:00
|
|
|
return JsonResponse({
|
|
|
|
|
'levity': [_card_dict(c) for c in available[:mid]],
|
|
|
|
|
'gravity': [_card_dict(c) for c in available[mid:]],
|
|
|
|
|
})
|
2026-04-28 23:02:49 -04:00
|
|
|
|
|
|
|
|
|
2026-04-28 22:16:38 -04:00
|
|
|
@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)
|
2026-04-28 23:02:49 -04:00
|
|
|
ctx['room'] = room
|
2026-05-01 00:11:40 -04:00
|
|
|
# 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))
|
2026-04-28 22:16:38 -04:00
|
|
|
return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx)
|
|
|
|
|
|