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
|
|
|
)
|
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD
Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring).
Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait.
Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
|
|
|
from apps.epic.utils import _compute_distinctions, _planet_house, card_dict, stack_reversal_probability, top_capacitors
|
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)
|
|
|
|
|
|
|
|
|
|
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
def _retract_prior_event(room, actor, verbs, slot_number=None):
|
|
|
|
|
"""Mark the most-recent unretracted GameEvent for `actor` on `room`
|
|
|
|
|
matching one of `verbs` (and optional `slot_number`) as retracted.
|
|
|
|
|
Drives the symmetric redact-pair pattern in the room scroll: every
|
|
|
|
|
state transition (deposit ↔ withdraw, sig-ready ↔ sig-unready) sets
|
|
|
|
|
`data.retracted=True` on its counterpart's prior entry, which the
|
|
|
|
|
scroll template renders strikethrough + Redact-tagged.
|
|
|
|
|
|
|
|
|
|
`verbs` is a list/tuple — e.g. when a deposit lands, retract the
|
|
|
|
|
prior SLOT_RETURNED *or* SLOT_RELEASED for the same slot (both
|
|
|
|
|
represent a withdraw of the slot in question). No-op if no matching
|
|
|
|
|
unretracted prior exists. Sprint A.8 user-spec 2026-05-26."""
|
|
|
|
|
qs = room.events.filter(actor=actor, verb__in=verbs)
|
|
|
|
|
if slot_number is not None:
|
|
|
|
|
qs = qs.filter(data__slot_number=slot_number)
|
|
|
|
|
prior = qs.last()
|
|
|
|
|
if prior and not prior.data.get("retracted"):
|
|
|
|
|
prior.data["retracted"] = True
|
|
|
|
|
prior.save(update_fields=["data"])
|
|
|
|
|
|
|
|
|
|
|
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
|
fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename
**CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:18:32 -04:00
|
|
|
# `equipped_deck_id` gates the JS role-select guard (role-select.js L165).
|
|
|
|
|
# Falls back to ANY of the user's seats in this room w. deck_variant set
|
|
|
|
|
# — covers the CARTE multi-seat return path where the first role-pick
|
|
|
|
|
# cleared `user.equipped_deck` (the deck is committed to seats, but still
|
|
|
|
|
# "in play"). Without this, a CARTE user navigating away + back gets
|
|
|
|
|
# "Equip card deck before Role select" wrongly fired. See [[sprint-carte-
|
|
|
|
|
# role-select-return-may18]] / commit-TBD for the bug trail.
|
|
|
|
|
role_select_deck_id = (
|
|
|
|
|
user.equipped_deck_id if user.is_authenticated else None
|
|
|
|
|
)
|
|
|
|
|
if user.is_authenticated and not role_select_deck_id:
|
|
|
|
|
seat_w_deck = room.table_seats.filter(
|
|
|
|
|
gamer=user, deck_variant__isnull=False,
|
|
|
|
|
).order_by("slot_number").first()
|
|
|
|
|
if seat_w_deck:
|
|
|
|
|
role_select_deck_id = seat_w_deck.deck_variant_id
|
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,
|
fix CARTE multi-seat Role-Select bug on navigate-away + back; My Sign applet rename
**CARTE bug** (user-reported on iPhone): a CARTE gamer who contributed their deck to multiple gate slots could fill ≥1 role for ≥1 seat, navigate away (BYE → dashboard, CONT GAME → return, etc.), come back to the room — and the JS guard on .card-stack would wrongly fire "Equip card deck before Role select" + block further role picks, even though the deck was demonstrably in play on existing seats. Symmetric for the "stay in room during Role Select" variant the user thought we'd squashed before (the prior fix was 759ce8d for the multi-slot SELECT path, but the room VIEW context never got the same treatment) ; **root cause**: `select_role()` at epic/views.py:619-621 clears `user.equipped_deck` after the first role pick ("deck committed to room"). The room view's role-select context at epic/views.py:286 then passes `equipped_deck_id = user.equipped_deck_id` to the template — which is now None — and the template renders `data-equipped-deck=""` → JS guard at role-select.js:165 sees the empty string and fires the "no deck" warning. The deck IS in play; the context just isn't recognizing seat-level deck assignment as a deck source ; **fix** (epic/views.py:286ish): when `user.equipped_deck_id` is None, fall back to the deck_variant of any of the user's seats in this room (order_by slot_number for determinism). The guard now sees a non-empty id and the fan opens. Storage-side unchanged — seat.deck_variant remains the canonical "this deck is in play on this seat" signal, and the user's deck-third contribution per role (PC=levity brands+crowns / NC=levity trumps / SC=levity grails+blades / AC=gravity grails+blades / EC=gravity trumps / BC=gravity brands+crowns) flows from existing `select_role` logic that inherits deck_variant from the first seat ; **TDD trail** — 2 new ITs in `SelectRoleMultiSeatTest` (apps.epic.tests.integrated.test_views): T1 pins the context (`response.context["equipped_deck_id"]` equals the existing seat's deck_variant_id after `user.equipped_deck` clears); T2 pins the template (rendered `data-equipped-deck="<id>"` not `""`). Initial reds — `None != 2` + `data-equipped-deck=""` substring assertion. Fix lands both green ; **bundled: My Sign applet rename** — user clarified naming convention 2026-05-18: **applets** use the "My X" prefix (My Sign, My Sea, My Posts), **standalone pages** use the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit page). Sprint 4a's initial migration set the applet name to "Game Sign" — corrected after the user saw the gear-menu toggle list reading the wrong word. Applet template header link "Game Sign" → "My Sign" (user-edited); migration 0010 added to update the Applet row's `name` in already-migrated DBs (dev + staging); applets/0009 frontmatter + defaults updated to "My Sign" in case of a fresh migrate-from-zero; test seed helpers in billboard test_views.py + functional_tests/test_bill_my_sign.py updated to "My Sign". Slug stays `my-sign` (URL + selectors stable) ; **bundled: rootvars.scss** — user-modified mid-session (pre-staged) ; 1022 IT/UT green in 46s — no regressions; 4 ITs in SelectRoleMultiSeatTest green (2 pre-existing CARTE multi-seat ITs + 2 new return-trip context ITs)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:18:32 -04:00
|
|
|
"equipped_deck_id": role_select_deck_id,
|
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()
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
# Redact-pair: a re-deposit on this slot strikes the most-
|
|
|
|
|
# recent unretracted withdraw entry for this slot (user-
|
|
|
|
|
# spec 2026-05-26 — symmetric mirror of the sig embody/
|
|
|
|
|
# disembody pattern).
|
|
|
|
|
_retract_prior_event(
|
|
|
|
|
room, request.user,
|
|
|
|
|
(GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED),
|
|
|
|
|
slot_number=int(slot_number),
|
|
|
|
|
)
|
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)
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
# Redact-pair: re-deposit on this slot strikes the prior
|
|
|
|
|
# unretracted withdraw entry for this slot (sprint A.8).
|
|
|
|
|
_retract_prior_event(
|
|
|
|
|
room, request.user,
|
|
|
|
|
(GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED),
|
|
|
|
|
slot_number=slot.slot_number,
|
|
|
|
|
)
|
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)
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
# CARTE full return: reset token + all CARTE-debited slots. Snapshot
|
|
|
|
|
# the slot numbers BEFORE the bulk update so we can emit a per-slot
|
|
|
|
|
# withdraw + redact pair (one entry per slot was deposited, so one
|
|
|
|
|
# entry per slot is withdrawn — symmetric mirror per user-spec
|
|
|
|
|
# 2026-05-26 sprint A.8).
|
2026-03-16 00:07:52 -04:00
|
|
|
carte = request.user.tokens.filter(
|
|
|
|
|
token_type=Token.CARTE, current_room=room
|
|
|
|
|
).first()
|
|
|
|
|
if carte:
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
carte_slot_numbers = list(
|
|
|
|
|
room.gate_slots.filter(
|
|
|
|
|
debited_token_type=Token.CARTE, gamer=request.user,
|
|
|
|
|
).values_list("slot_number", flat=True)
|
|
|
|
|
)
|
2026-03-16 00:07:52 -04:00
|
|
|
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)
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
for n in carte_slot_numbers:
|
|
|
|
|
_retract_prior_event(
|
|
|
|
|
room, request.user, (GameEvent.SLOT_FILLED,), slot_number=n,
|
|
|
|
|
)
|
|
|
|
|
record(room, GameEvent.SLOT_RETURNED, actor=request.user,
|
|
|
|
|
slot_number=n, token_type=Token.CARTE,
|
|
|
|
|
token_display=carte.get_token_type_display())
|
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:
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
# Snapshot token-type + slot-number BEFORE the slot reset so the
|
|
|
|
|
# log entry carries the right payload.
|
|
|
|
|
withdraw_token_type = slot.debited_token_type
|
|
|
|
|
withdraw_slot_number = slot.slot_number
|
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)
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
was_filled = slot.status == GateSlot.FILLED
|
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()
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
# Only emit a withdraw entry when a deposit was actually undone
|
|
|
|
|
# (FILLED → EMPTY). RESERVED → EMPTY is a pre-confirm cancel
|
|
|
|
|
# that never recorded a SLOT_FILLED, so no redact-pair fires.
|
|
|
|
|
if was_filled and withdraw_token_type:
|
|
|
|
|
_retract_prior_event(
|
|
|
|
|
room, request.user, (GameEvent.SLOT_FILLED,),
|
|
|
|
|
slot_number=withdraw_slot_number,
|
|
|
|
|
)
|
|
|
|
|
token_display = dict(Token.TOKEN_TYPE_CHOICES).get(
|
|
|
|
|
withdraw_token_type, withdraw_token_type,
|
|
|
|
|
)
|
|
|
|
|
record(room, GameEvent.SLOT_RETURNED, actor=request.user,
|
|
|
|
|
slot_number=withdraw_slot_number,
|
|
|
|
|
token_type=withdraw_token_type,
|
|
|
|
|
token_display=token_display)
|
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):
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
"""Un-fill a single CARTE-claimed slot without returning the CARTE itself.
|
|
|
|
|
|
|
|
|
|
Emits a SLOT_RELEASED event (renders w. the unified withdraw prose,
|
|
|
|
|
shape-matched to the deposit) and retracts the corresponding prior
|
|
|
|
|
SLOT_FILLED so the room scroll renders the redact-pair per user-spec
|
|
|
|
|
2026-05-26 (sprint A.8).
|
|
|
|
|
"""
|
2026-03-16 00:07:52 -04:00
|
|
|
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:
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
released_slot_number = slot.slot_number
|
2026-03-16 00:07:52 -04:00
|
|
|
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()
|
sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:55:26 -04:00
|
|
|
_retract_prior_event(
|
|
|
|
|
room, request.user, (GameEvent.SLOT_FILLED,),
|
|
|
|
|
slot_number=released_slot_number,
|
|
|
|
|
)
|
|
|
|
|
record(room, GameEvent.SLOT_RELEASED, actor=request.user,
|
|
|
|
|
slot_number=released_slot_number, token_type=Token.CARTE,
|
|
|
|
|
token_display=dict(Token.TOKEN_TYPE_CHOICES).get(
|
|
|
|
|
Token.CARTE, "Carte Blanche"))
|
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: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({
|
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD
Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring).
Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait.
Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
|
|
|
'levity': [card_dict(c, reversal_prob) for c in available[:mid]],
|
|
|
|
|
'gravity': [card_dict(c, reversal_prob) for c in available[mid:]],
|
2026-04-28 23:30:07 -04:00
|
|
|
})
|
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)
|
|
|
|
|
|