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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user