diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 4386e8c..90f6445 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -24,6 +24,8 @@ class GameEvent(models.Model): # Sig Select phase SIG_READY = "sig_ready" SIG_UNREADY = "sig_unready" + # Sky Select phase + SKY_SAVED = "sky_saved" VERB_CHOICES = [ (ROOM_CREATED, "Room created"), @@ -37,6 +39,7 @@ class GameEvent(models.Model): (ROLES_REVEALED, "Roles revealed"), (SIG_READY, "Sig claim staked"), (SIG_UNREADY, "Sig claim withdrawn"), + (SKY_SAVED, "Sky saved"), ] room = models.ForeignKey( @@ -114,6 +117,26 @@ class GameEvent(models.Model): if self.verb == self.SIG_UNREADY: _, _, poss = _actor_pronouns(self.actor) return f"disembodies {poss} Significator." + if self.verb == self.SKY_SAVED: + _, obj, poss = _actor_pronouns(self.actor) + caps = list(d.get("top_capacitors") or []) + if not caps: + return f"beholds the skyscape of {poss} birth." + if len(caps) == 1: + return ( + f"beholds the skyscape of {poss} birth, " + f"which yields {obj} a unique {caps[0]} capacity." + ) + # Tied highest: "equal X and Y capacities" (2-way) or + # "equal X, Y, and Z capacities" (3+, Oxford comma). + if len(caps) == 2: + joined = f"{caps[0]} and {caps[1]}" + else: + joined = ", ".join(caps[:-1]) + f", and {caps[-1]}" + return ( + f"beholds the skyscape of {poss} birth, " + f"which yields {obj} equal {joined} capacities." + ) return self.verb @property diff --git a/src/apps/drama/tests/integrated/test_models.py b/src/apps/drama/tests/integrated/test_models.py index 0bbd72d..9507034 100644 --- a/src/apps/drama/tests/integrated/test_models.py +++ b/src/apps/drama/tests/integrated/test_models.py @@ -119,6 +119,49 @@ class GameEventModelTest(TestCase): role="PC", role_display="Player") self.assertIn("it will start the game", event.to_prose()) + # ── to_prose — SKY_SAVED ────────────────────────────────────────────── + + def test_sky_saved_prose_single_capacitor(self): + # Default user pronouns = pluralism → "their" + "them". + event = record(self.room, GameEvent.SKY_SAVED, actor=self.user, + top_capacitors=["Ardor"]) + prose = event.to_prose() + self.assertIn( + "beholds the skyscape of their birth, which yields them a unique Ardor capacity.", + prose, + ) + + def test_sky_saved_prose_two_way_tie(self): + # Tied highest scores: pluralize "a unique" → "equal", join w. "and", + # pluralize "capacity" → "capacities". + event = record(self.room, GameEvent.SKY_SAVED, actor=self.user, + top_capacitors=["Ardor", "Ossum"]) + prose = event.to_prose() + self.assertIn( + "beholds the skyscape of their birth, which yields them equal Ardor and Ossum capacities.", + prose, + ) + + def test_sky_saved_prose_three_way_tie_uses_oxford_comma(self): + event = record(self.room, GameEvent.SKY_SAVED, actor=self.user, + top_capacitors=["Ardor", "Ossum", "Pneuma"]) + prose = event.to_prose() + self.assertIn( + "beholds the skyscape of their birth, which yields them equal Ardor, Ossum, and Pneuma capacities.", + prose, + ) + + def test_sky_saved_prose_uses_actor_pronouns(self): + self.user.pronouns = "bawlmorese" + self.user.save(update_fields=["pronouns"]) + event = record(self.room, GameEvent.SKY_SAVED, actor=self.user, + top_capacitors=["Tempo"]) + # Bawlmorese → poss "yos", obj "yo". + self.assertIn( + "beholds the skyscape of yos birth, which yields yo a unique Tempo capacity.", + event.to_prose(), + ) + def test_sig_ready_prose_degrades_without_corner_rank(self): # Old events recorded before this change have no corner_rank key event = record(self.room, GameEvent.SIG_READY, actor=self.user, diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 723ef3f..4a82121 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2075,6 +2075,86 @@ class NatusSaveViewTest(TestCase): self.assertEqual(response.status_code, 200) self.assertTrue(response.json()["confirmed"]) + def test_confirm_records_sky_saved_event_with_top_capacitors(self): + """When action=confirm, log a SKY_SAVED GameEvent w. the highest-count + capacitor name(s) so the billscroll can render the new prose.""" + from apps.drama.models import GameEvent + chart = { + "elements": { + # Earthman uses 6 elements; canonical names map to capacitors: + # Fire→Ardor Stone→Ossum Air→Pneuma Water→Humor Time→Tempo Space→Nexus. + "Fire": 3, + "Stone": 1, + "Air": 2, + "Water": 0, + "Time": 1, + "Space": 1, + } + } + self._post({ + "birth_dt": "1990-06-15T09:00:00Z", + "birth_lat": 51.5, "birth_lon": -0.1, + "birth_place": "", "house_system": "O", + "chart_data": chart, "action": "confirm", + }) + event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED) + self.assertEqual(event.actor, self.user) + self.assertEqual(event.data.get("top_capacitors"), ["Ardor"]) + + def test_confirm_records_sky_saved_event_with_two_way_tie(self): + from apps.drama.models import GameEvent + chart = { + "elements": { + "Fire": 3, "Stone": 3, # tied at top + "Air": 2, "Water": 0, "Time": 1, "Space": 1, + } + } + self._post({ + "birth_dt": "1990-06-15T09:00:00Z", + "birth_lat": 51.5, "birth_lon": -0.1, + "birth_place": "", "house_system": "O", + "chart_data": chart, "action": "confirm", + }) + event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED) + # Order follows the canonical ELEMENT_ORDER (Fire, Stone, Time, Space, Air, Water) + self.assertEqual(event.data.get("top_capacitors"), ["Ardor", "Ossum"]) + + def test_save_without_confirm_does_not_record_sky_saved_event(self): + from apps.drama.models import GameEvent + self._post({ + "birth_dt": "1990-06-15T09:00:00Z", + "birth_lat": 51.5, "birth_lon": -0.1, + "birth_place": "", "house_system": "O", + "chart_data": {"elements": {"Fire": 3}}, + # no action=confirm — just a draft save + }) + self.assertFalse( + GameEvent.objects.filter(room=self.room, verb=GameEvent.SKY_SAVED).exists() + ) + + def test_confirm_with_dict_shaped_elements_extracts_count(self): + """Some chart payloads enrich each element to {count, contributors}; + natus_save should read .count rather than treating the dict as a value.""" + from apps.drama.models import GameEvent + chart = { + "elements": { + "Fire": {"count": 4, "contributors": ["Sun", "Mars", "Jupiter", "Pluto"]}, + "Stone": {"count": 1, "contributors": ["Venus"]}, + "Air": {"count": 2, "contributors": ["Mercury", "Uranus"]}, + "Water": {"count": 0, "contributors": []}, + "Time": {"count": 1, "stellia": ["Saturn"]}, + "Space": {"count": 1, "parades": ["Neptune"]}, + } + } + self._post({ + "birth_dt": "1990-06-15T09:00:00Z", + "birth_lat": 51.5, "birth_lon": -0.1, + "birth_place": "", "house_system": "O", + "chart_data": chart, "action": "confirm", + }) + event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED) + self.assertEqual(event.data.get("top_capacitors"), ["Ardor"]) + def test_confirm_copies_seat_significator_to_character(self): """natus_save with action=confirm copies seat.significator onto Character.""" earthman, _ = DeckVariant.objects.get_or_create( diff --git a/src/apps/epic/utils.py b/src/apps/epic/utils.py index 89b7559..74d4761 100644 --- a/src/apps/epic/utils.py +++ b/src/apps/epic/utils.py @@ -25,6 +25,43 @@ def stack_reversal_probability(user=None, room=None): +# Element key → in-game capacitor name (mirrors ELEMENT_INFO in natus-wheel.js). +# Used by the SKY_SAVED provenance event to render prose like +# "yields them a unique Ardor capacity." +ELEMENT_CAPACITOR_NAMES = { + "Fire": "Ardor", + "Stone": "Ossum", + "Time": "Tempo", + "Space": "Nexus", + "Air": "Pneuma", + "Water": "Humor", +} +# Canonical clockwise-ring ordering for tie-break and prose joining. +ELEMENT_ORDER = ["Fire", "Stone", "Time", "Space", "Air", "Water"] + + +def top_capacitors(elements): + """Return capacitor names tied for the highest count in `elements`. + + `elements` is the chart-data dict whose values are either ints (raw counts) + or {"count": int, ...} enriched dicts. Order follows ELEMENT_ORDER so tied + output is deterministic across runs and matches the wheel's visual order. + """ + if not elements: + return [] + def _count(v): + return v.get("count", 0) if isinstance(v, dict) else (v or 0) + counts = {k: _count(v) for k, v in elements.items()} + if not counts or max(counts.values()) <= 0: + return [] + top = max(counts.values()) + return [ + ELEMENT_CAPACITOR_NAMES[k] + for k in ELEMENT_ORDER + if counts.get(k) == top and k in ELEMENT_CAPACITOR_NAMES + ] + + def _planet_house(degree, cusps): """Return 1-based house number for a planet at ecliptic degree. diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 5f53fc2..3e94cb3 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -22,7 +22,7 @@ from apps.epic.models import ( active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, select_token, sig_deck_cards, ) -from apps.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability +from apps.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability, top_capacitors from apps.lyric.models import Token @@ -1114,6 +1114,12 @@ def natus_save(request, room_id): char.save() if char.is_confirmed: + from apps.drama.models import GameEvent, record + caps = top_capacitors((char.chart_data or {}).get('elements')) + record( + room, GameEvent.SKY_SAVED, actor=request.user, + top_capacitors=caps, + ) _notify_sky_confirmed(room_id, seat.role) return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed}) diff --git a/src/functional_tests/test_room_sea_select.py b/src/functional_tests/test_room_sea_select.py index e5a6077..5859374 100644 --- a/src/functional_tests/test_room_sea_select.py +++ b/src/functional_tests/test_room_sea_select.py @@ -38,7 +38,9 @@ def _make_sky_confirmed_room(live_server_url, user, earthman): @tag("channels") class PickSeaAsyncTransitionTest(ChannelsFunctionalTest): - """After sky confirm, PICK SEA overlay appears without a page refresh.""" + """After sky confirm, the natus overlay closes and the room reloads to the + table hex w. the PICK SEA btn visible — the gamer must opt into the sea + overlay rather than be auto-launched into it.""" def setUp(self): super().setUp() @@ -89,51 +91,55 @@ class PickSeaAsyncTransitionTest(ChannelsFunctionalTest): }}); """) - def test_sea_overlay_appears_without_page_refresh(self): - """Confirming sky replaces the natus overlay with the sea overlay in-place.""" + def test_pick_sea_btn_visible_after_sky_confirm(self): + """Confirming sky reloads the room to the hex w. PICK SEA replacing + PICK SKY; the sea overlay is NOT auto-opened.""" self.create_pre_authenticated_session("founder@test.io") self.browser.get(self.room_url) - # Sky not yet confirmed — PICK SKY btn present, no sea overlay + # Sky not yet confirmed — PICK SKY btn present. self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) - self.assertEqual(self.browser.find_elements(By.ID, "id_sea_overlay"), []) self._confirm_sky() - # Sea overlay appears without page refresh + # Page reloads → hex shows PICK SEA in place of PICK SKY. + self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn")) + self.assertEqual(self.browser.find_elements(By.ID, "id_pick_sky_btn"), []) + + # Sea overlay is NOT auto-opened — it only appears once the gamer + # clicks PICK SEA. + has_sea_open = self.browser.execute_script( + "return document.documentElement.classList.contains('sea-open');" + ) + self.assertFalse(has_sea_open) + + def test_natus_overlay_closed_after_sky_confirm(self): + """Natus overlay is gone (page reloaded) after sky confirm.""" + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.room_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) + + self._confirm_sky() + + self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn")) + natus = self.browser.find_elements(By.ID, "id_natus_overlay") + self.assertTrue(not natus or not natus[0].is_displayed()) + + def test_clicking_pick_sea_btn_opens_sea_overlay(self): + """The gamer's explicit click on PICK SEA is what opens the sea overlay.""" + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.room_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) + + self._confirm_sky() + pick_sea = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sea_btn")) + self.browser.execute_script("arguments[0].click()", pick_sea) + sea_overlay = self.wait_for( lambda: self.browser.find_element(By.ID, "id_sea_overlay") ) self.assertTrue(sea_overlay.is_displayed()) - def test_natus_overlay_not_visible_after_sky_confirm(self): - """Natus overlay is removed from the DOM after sky confirm.""" - self.create_pre_authenticated_session("founder@test.io") - self.browser.get(self.room_url) - self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) - - self._confirm_sky() - - # Sea overlay must appear first (confirms transition happened) - self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay")) - - natus = self.browser.find_elements(By.ID, "id_natus_overlay") - self.assertTrue(not natus or not natus[0].is_displayed()) - - def test_sea_open_class_on_html_after_confirm(self): - """html.sea-open is set after sky confirm, giving the sea overlay its backdrop.""" - self.create_pre_authenticated_session("founder@test.io") - self.browser.get(self.room_url) - self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) - - self._confirm_sky() - - self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay")) - has_sea_open = self.browser.execute_script( - "return document.documentElement.classList.contains('sea-open');" - ) - self.assertTrue(has_sea_open) - # ── Helpers for PICK SEA deal tests ────────────────────────────────────────── diff --git a/src/templates/apps/gameboard/_partials/_natus_overlay.html b/src/templates/apps/gameboard/_partials/_natus_overlay.html index cf376f9..3765aca 100644 --- a/src/templates/apps/gameboard/_partials/_natus_overlay.html +++ b/src/templates/apps/gameboard/_partials/_natus_overlay.html @@ -383,26 +383,16 @@ }); }); - // ── Sky confirmed → inject sea partial ─────────────────────────────────── - - const SEA_PARTIAL_URL = overlay.dataset.seaPartialUrl; + // ── Sky confirmed → close natus & reload to land on hex w. PICK SEA ────── + // + // The gamer should witness the table hex (now showing PICK SEA in place of + // PICK SKY) before opting into the sea overlay. We reload the room page — + // the server-side template will re-render with `sky_confirmed=True` so the + // hex's btn flips automatically, and the user clicks PICK SEA to continue. function _onSkyConfirmed() { - fetch(SEA_PARTIAL_URL, { credentials: 'same-origin' }) - .then(r => r.text()) - .then(html => { - // Remove natus overlay + backdrop; inject sea partial before body close - var backdrop = document.querySelector('.natus-backdrop'); - if (backdrop) backdrop.remove(); - overlay.remove(); - document.documentElement.classList.remove('natus-open'); - document.body.insertAdjacentHTML('beforeend', html); - document.documentElement.classList.add('sea-open'); - }) - .catch(() => { - // Fallback: just close natus and let page refresh handle the transition - closeNatus(); - }); + closeNatus(); + window.location.reload(); } // ── CSRF ──────────────────────────────────────────────────────────────────