diff --git a/src/apps/epic/migrations/0019_room_convened_at_room_sky_chart.py b/src/apps/epic/migrations/0019_room_convened_at_room_sky_chart.py new file mode 100644 index 0000000..3a149f9 --- /dev/null +++ b/src/apps/epic/migrations/0019_room_convened_at_room_sky_chart.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2026-06-10 03:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0018_remove_sigreservation_one_sig_reservation_per_gamer_per_room_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='room', + name='convened_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='room', + name='sky_chart', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index e29d826..0fcec00 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -57,6 +57,13 @@ class Room(models.Model): created_at = models.DateTimeField(auto_now_add=True) board_state = models.JSONField(default=dict) seed_count = models.IntegerField(default=12) + # The table's OWN sky — the shared SEED MAP rim frame ([[project-voronoi-spec]]). + # convened_at = the gate-close moment (table_status first set in pick_roles); + # sky_chart = the planets-only chart cast for that moment at the null location + # (houses/ASC/MC need a birth LOCATION a virtual table doesn't have), computed + # LAZILY by the table_sky view — legacy rooms key off created_at. + convened_at = models.DateTimeField(null=True, blank=True) + sky_chart = models.JSONField(null=True, blank=True) def get_thread_post(self): """Get-or-create this room's single game-table thread Post (the POST diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 4c534ee..dc3b034 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1102,6 +1102,15 @@ class PickRolesViewTest(TestCase): self.client.post(url) # second call must be a no-op self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6) + def test_pick_roles_stamps_convened_at(self): + """The table's convene moment — the timestamp its OWN sky is cast from + (the shared SEED MAP rim frame; planets-only, no location). Stamped at + the gate-close transition; the chart itself computes LAZILY elsewhere + so this transition never fires HTTP.""" + self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id})) + self.room.refresh_from_db() + self.assertIsNotNone(self.room.convened_at) + class SelectRoleViewTest(TestCase): def setUp(self): @@ -2909,6 +2918,104 @@ class SkyPreviewViewTest(TestCase): self.assertNotIn("Earth", data["elements"]) +class TableSkyViewTest(TestCase): + """The table's OWN sky — the shared SEED MAP rim frame ([[project-voronoi-spec]]). + Planets-only: geocentric longitudes need only the convened TIME, while + houses/ASC/MC need a birth LOCATION a virtual table doesn't have. Computed + LAZILY on first request via PySwiss at the null location and cached on + Room.sky_chart — never at the pick_roles transition, so gate-close tests + stay HTTP-free.""" + + _PLANETS = {"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}} + + def setUp(self): + self.user = User.objects.create(email="pc@tablesky.io") + self.client.force_login(self.user) + self.room, _ = _make_sig_room(self.user) + self.room.table_status = Room.SKY_SELECT + self.room.convened_at = timezone.now() + self.room.save() + self.url = reverse("epic:table_sky", kwargs={"room_id": self.room.id}) + + @patch("apps.epic.views.http_requests") + def test_returns_stored_chart_without_calling_pyswiss(self, mock_requests): + self.room.sky_chart = {"planets": self._PLANETS, "aspects": []} + self.room.save(update_fields=["sky_chart"]) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["planets"], self._PLANETS) + mock_requests.get.assert_not_called() + + @patch("apps.epic.views.http_requests") + def test_computes_planets_only_at_null_location_and_caches(self, mock_requests): + from unittest.mock import MagicMock + ch_r = MagicMock() + ch_r.json.return_value = { + "planets": self._PLANETS, + "houses": {"cusps": [0] * 12, "asc": 0, "mc": 270}, + "elements": {"Earth": 1}, + "aspects": [{"planet1": "Sun", "planet2": "Moon", "type": "Trine", + "orb": 1.2, "applying_planet": "Moon"}], + } + ch_r.raise_for_status = MagicMock() + mock_requests.get.return_value = ch_r + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + data = response.json() + # Location-independent keys survive; location-bound houses NEVER do. + self.assertEqual(data["planets"], self._PLANETS) + self.assertEqual(len(data["aspects"]), 1) + self.assertNotIn("houses", data) + # Cast at the convened moment, at the null location. + _, kwargs = mock_requests.get.call_args + self.assertEqual(kwargs["params"]["lat"], "0") + self.assertEqual(kwargs["params"]["lon"], "0") + self.assertEqual( + kwargs["params"]["dt"], + self.room.convened_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + # ...and cached: the second GET serves the store, no second call. + self.room.refresh_from_db() + self.assertEqual(self.room.sky_chart["planets"], self._PLANETS) + self.client.get(self.url) + self.assertEqual(mock_requests.get.call_count, 1) + + @patch("apps.epic.views.http_requests") + def test_falls_back_to_created_at_for_legacy_rooms(self, mock_requests): + """Rooms that convened before the stamp existed key off created_at.""" + from unittest.mock import MagicMock + self.room.convened_at = None + self.room.save(update_fields=["convened_at"]) + ch_r = MagicMock() + ch_r.json.return_value = {"planets": self._PLANETS} + ch_r.raise_for_status = MagicMock() + mock_requests.get.return_value = ch_r + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + _, kwargs = mock_requests.get.call_args + self.assertEqual( + kwargs["params"]["dt"], + self.room.created_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + + @patch("apps.epic.views.http_requests") + def test_pyswiss_failure_returns_502_and_caches_nothing(self, mock_requests): + from unittest.mock import MagicMock + ch_r = MagicMock() + ch_r.raise_for_status.side_effect = Exception("down") + mock_requests.get.return_value = ch_r + response = self.client.get(self.url) + self.assertEqual(response.status_code, 502) + self.room.refresh_from_db() + self.assertIsNone(self.room.sky_chart) + + def test_unseated_gamer_gets_403(self): + outsider = User.objects.create(email="outsider@tablesky.io") + self.client.force_login(outsider) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + # ── tarot_deal ──────────────────────────────────────────────────────────────── class TarotDealViewTest(TestCase): @@ -4634,6 +4741,34 @@ class PickSeaUnifiedFeltTest(TestCase): content = self.client.get(self.url).content.decode() self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3) + def test_seed_map_overlay_renders_shared_room_sky_rim(self): + """Step 2's shared frame: the felt carries the wheel-rim svg + the + ROOM's own sky (one canonical frame for all six gamers — NOT the + viewing seat's natal chart), drawn via SkyWheel.drawRim. sky-wheel.js + must NOT be re-included — the sky overlay already loads it, and a + second top-level `const SkyWheel` declaration throws.""" + self._complete_hand() + self.room.sky_chart = { + "planets": {"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}}, + "aspects": [], + } + self.room.save(update_fields=["sky_chart"]) + content = self.client.get(self.url).content.decode() + self.assertIn('id="id_seed_wheel_svg"', content) + self.assertIn("SkyWheel.drawRim", content) + self.assertIn("338.4", content) # the room sky, embedded + self.assertIn("data-table-sky-url", content) # lazy compute endpoint + self.assertEqual(content.count("apps/gameboard/sky-wheel.js"), 1) + + def test_seed_map_overlay_rim_survives_missing_room_sky(self): + """No stored room sky yet → the rim svg + lazy fetch URL still render: + the felt fetches table_sky on open (which computes + caches), and the + canonical signs-only frame draws while the planets are absent.""" + self._complete_hand() + content = self.client.get(self.url).content.decode() + self.assertIn('id="id_seed_wheel_svg"', content) + self.assertIn("data-table-sky-url", content) + def test_sea_felt_leaves_sky_btn_lit_and_swaps_cleanly(self): """Symmetric with Sky Select (which leaves the sea btn lit while the sky felt is up): opening the Sea Select felt must NOT grey the burger Sky diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index e06ae29..86035cf 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -32,6 +32,7 @@ urlpatterns = [ path('room//sky/preview', views.sky_preview, name='sky_preview'), path('room//sky/save', views.sky_save, name='sky_save'), path('room//sky/delete', views.sky_delete, name='sky_delete'), + path('room//sky/table', views.table_sky, name='table_sky'), path('room//sea/partial', views.sea_partial, name='sea_partial'), path('room//sea/deck', views.sea_deck, name='sea_deck'), path('room//sea/save', views.sea_save, name='sea_save'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index a38026f..689cd8e 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -696,6 +696,11 @@ def _role_select_context(room, user, seat_param=None): if (sky_confirmed and confirmed_char.chart_data) else "null" ) + # The table's OWN sky — the shared SEED MAP rim frame (one canonical + # frame for all six gamers, NOT this seat's natal chart). None until + # the lazy table_sky endpoint computes + caches it; the felt then + # fetches it on open. + ctx["room_sky_json"] = json.dumps(room.sky_chart) if room.sky_chart else None if sky_confirmed: # Fall back to seat.significator for Characters created before the sync was added ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator @@ -1282,6 +1287,10 @@ def pick_roles(request, room_id): room = Room.objects.get(id=room_id) if room.gate_status == Room.OPEN and room.table_status is None: room.table_status = Room.ROLE_SELECT + # The table's convene moment — the timestamp its OWN sky is cast + # from (the shared SEED MAP rim). Stamp only; the chart computes + # LAZILY in table_sky so this transition never fires HTTP. + room.convened_at = timezone.now() room.save() for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"): TableSeat.objects.create( @@ -1823,6 +1832,47 @@ def sky_preview(request, room_id): return JsonResponse(data) +@login_required +def table_sky(request, room_id): + """The table's OWN sky — the shared SEED MAP rim frame ([[project-voronoi-spec]]). + + Planets-only: geocentric longitudes need only the convened TIME (gate + close, Room.convened_at; legacy rooms key off created_at), while houses/ + ASC/MC need a birth LOCATION a virtual table doesn't have — so PySwiss is + queried at the null location and only the location-independent keys + (planets, aspects) are kept. Computed LAZILY here on first request and + cached on Room.sky_chart — never at the pick_roles transition, which must + stay HTTP-free. + + Returns {planets, aspects} 200 · 403 not seated · 502 PySwiss unreachable. + """ + room = Room.objects.get(id=room_id) + if _canonical_user_seat(room, request.user) is None: + return HttpResponse(status=403) + if room.sky_chart: + return JsonResponse(room.sky_chart) + + convened = room.convened_at or room.created_at + dt_iso = convened.astimezone(zoneinfo.ZoneInfo('UTC')).strftime('%Y-%m-%dT%H:%M:%SZ') + try: + resp = http_requests.get( + settings.PYSWISS_URL + '/api/chart/', + params={'dt': dt_iso, 'lat': '0', 'lon': '0'}, + timeout=5, + ) + resp.raise_for_status() + except Exception: + return HttpResponse(status=502) + + data = resp.json() + room.sky_chart = { + 'planets': data.get('planets') or {}, + 'aspects': data.get('aspects') or [], + } + room.save(update_fields=['sky_chart']) + return JsonResponse(room.sky_chart) + + @login_required def sky_save(request, room_id): """Create or update the draft Character for the requesting gamer's seat. diff --git a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js index 45117e4..31187fa 100644 --- a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js @@ -1443,6 +1443,136 @@ const SkyWheel = (() => { }); } + // ── SEED MAP rim ────────────────────────────────────────────────────────── + + /** + * drawRim — the SEED MAP's shared wheel rim (roadmap step 21, Step 2; see + * project_voronoi_spec): the ROOM's own sky, planets only — a virtual table + * has a convened TIME but no birth LOCATION, so houses/ASC/MC never exist + * here — around the canonical signs ring (asc=0 frame: 0° Aries at 9 + * o'clock, identical for all six gamers). Strips the element ring + centre + * disc (the freed hub hosts the tessellation) and the aspect web. + * + * A PURE renderer, deliberately apart from draw(): no tooltips, no cycle, + * no click handlers, no entry transitions (the felt's geometry asserts read + * final positions immediately) and NO singleton writes — the interactive + * saved wheel on the same page (_svg/_currentData/#id_sky_tooltip) must + * survive a rim draw untouched. Reads the module's _signPaths cache + * (preload()ed by the sky overlay, which renders on every SKY_SELECT page). + * + * Returns {size, cx, cy, r, hubR} — hubR (just inside the planet band) is + * the radius the felt sizes the tessellation svg into (2 × hubR square + + * circle clip), or null without an svg/d3. + */ + function drawRim(svgEl, data) { + if (!svgEl || !window.d3) return null; + const sel = d3.select(svgEl); + sel.selectAll('*').remove(); + + const rect = svgEl.getBoundingClientRect(); + const size = Math.min(rect.width || 400, rect.height || 400); + const cx = size / 2, cy = size / 2; + svgEl.setAttribute('viewBox', `0 0 ${size} ${size}`); + svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + const r = size * 0.46; + const signInner = r * 0.70, signOuter = r * 0.90; + const labelR = r * 0.80, planetR = r * 0.59, tickOuter = r * 0.96; + const asc = 0; // canonical frame — no location, no Ascendant + + const g = sel.append('g').attr('class', 'nw-root nw-root--rim'); + + g.append('circle') + .attr('cx', cx).attr('cy', cy).attr('r', signOuter) + .attr('class', 'nw-outer-ring'); + + // Signs — canonical orientation, non-interactive. + const arc = d3.arc(); + const sigGroup = g.append('g').attr('class', 'nw-signs'); + SIGNS.forEach((sign, i) => { + const startA = _toAngle(i * 30, asc); + const endA = _toAngle(i * 30 + 30, asc); + const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA]; + const slice = sigGroup.append('g') + .attr('class', 'nw-sign-group') + .attr('data-sign-name', sign.name); + slice.append('path') + .attr('transform', `translate(${cx},${cy})`) + .attr('d', arc({ + innerRadius: signInner, + outerRadius: signOuter, + startAngle: sa + Math.PI / 2, + endAngle: ea + Math.PI / 2, + })) + .attr('class', `nw-sign--${sign.element.toLowerCase()}`); + const midA = (sa + ea) / 2; + const lx = cx + labelR * Math.cos(midA); + const ly = cy + labelR * Math.sin(midA); + const cr = r * 0.065; + const sf = (cr * 2 * 0.85) / 640; + slice.append('circle') + .attr('cx', lx).attr('cy', ly).attr('r', cr) + .attr('class', `nw-sign-icon-bg nw-sign-icon-bg--${sign.element.toLowerCase()}`); + if (_signPaths[sign.name]) { + slice.append('path') + .attr('d', _signPaths[sign.name]) + .attr('transform', `translate(${lx},${ly}) scale(${sf}) translate(-320,-320)`) + .attr('class', `nw-sign-icon nw-sign-icon--${sign.element.toLowerCase()}`); + } + }); + + // Planets — static at their final canonical angles. + const planetGroup = g.append('g').attr('class', 'nw-planets'); + Object.entries((data && data.planets) || {}).forEach(([name, pdata]) => { + const a = _toAngle(pdata.degree, asc); + const el = PLANET_ELEMENTS[name] || ''; + const px = cx + planetR * Math.cos(a); + const py = cy + planetR * Math.sin(a); + const grp = planetGroup.append('g') + .attr('class', 'nw-planet-group') + .attr('data-planet', name) + .attr('data-sign', pdata.sign) + .attr('data-degree', pdata.degree.toFixed(1)) + .attr('data-retrograde', pdata.retrograde ? 'true' : 'false'); + grp.append('line') + .attr('class', el ? `nw-planet-tick nw-planet-tick--${el}` : 'nw-planet-tick') + .attr('x1', px).attr('y1', py) + .attr('x2', cx + tickOuter * Math.cos(a)) + .attr('y2', cy + tickOuter * Math.sin(a)); + const circleBase = pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle'; + grp.append('circle') + .attr('cx', px).attr('cy', py).attr('r', r * 0.05) + .attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase); + grp.append('text') + .attr('x', px).attr('y', py) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('dy', '0.1em') + .attr('font-size', `${r * 0.09}px`) + .attr('class', el ? `nw-planet-label nw-planet-label--${el}` : 'nw-planet-label') + .attr('pointer-events', 'none') + .text(PLANET_SYMBOLS[name] || name[0]); + if (pdata.retrograde) { + const rxR = planetR + r * 0.07; + grp.append('circle') + .attr('cx', cx + rxR * Math.cos(a)).attr('cy', cy + rxR * Math.sin(a)) + .attr('r', r * 0.035) + .attr('class', 'nw-rx-badge') + .attr('pointer-events', 'none'); + grp.append('text') + .attr('x', cx + rxR * Math.cos(a)).attr('y', cy + rxR * Math.sin(a)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', `${r * 0.045}px`) + .attr('class', 'nw-rx') + .attr('pointer-events', 'none') + .text('℞'); + } + }); + + // The freed hub — just inside the planet band (planetR 0.59 − glyph 0.05). + return { size, cx, cy, r, hubR: r * 0.52 }; + } + // ── Public API ──────────────────────────────────────────────────────────── /** @@ -1519,5 +1649,5 @@ const SkyWheel = (() => { _currentData = null; } - return { preload, draw, redraw, clear }; + return { preload, draw, drawRim, redraw, clear }; })(); diff --git a/src/functional_tests/test_game_room_seed_map.py b/src/functional_tests/test_game_room_seed_map.py index 13ffee9..b7574b6 100644 --- a/src/functional_tests/test_game_room_seed_map.py +++ b/src/functional_tests/test_game_room_seed_map.py @@ -1,4 +1,4 @@ -"""Functional test for the SEED MAP felt — Voronoi map, roadmap step 21, Step 1. +"""Functional test for the SEED MAP felt — Voronoi map, roadmap step 21. The SEED MAP felt is the inline --duoUser sibling of the CAST SKY / DRAW SEA felts: once the seat's Celtic-Cross hand is complete (hand_complete), SEED MAP @@ -7,6 +7,13 @@ d3-delaunay DUAL GRAPH — a Voronoi cell layer (territory) + a Delaunay edge layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2), so we assert only that the dual graph renders both layers. +Step 2's first piece — the SHARED WHEEL RIM: the stripped sky wheel (signs ring +at canonical orientation + planet glyphs; NO element ring, centre disc, houses +or axes — a virtual table has a convened TIME but no birth LOCATION) rings the +tessellation, drawn from the ROOM'S OWN sky (Room.sky_chart, identical for all +six gamers). The map svg shrinks into the wheel's freed hub. The fixture +pre-stores Room.sky_chart so the lazy PySwiss compute path never fires HTTP. + Plain FunctionalTest (NOT @tag("channels")): the felt-open is pure client-side. We seed a CONFIRMED Character with a complete hand directly in the DB, so there is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs. @@ -25,6 +32,17 @@ from .test_game_room_select_sea import _make_sky_confirmed_room _HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"] +# The room's own sky — planets only (location-independent), mirroring the +# Jasmine ROOM_SKY fixture. Mercury retrograde exercises the ℞ badge. +_ROOM_SKY = { + "planets": { + "Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}, + "Moon": {"sign": "Capricorn", "degree": 295.1, "retrograde": False}, + "Mercury": {"sign": "Aquarius", "degree": 312.8, "retrograde": True}, + }, + "aspects": [], +} + class SeedMapFeltTest(FunctionalTest): def setUp(self): @@ -53,6 +71,10 @@ class SeedMapFeltTest(FunctionalTest): celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete => SEED MAP is the live phase btn + the felt renders.""" room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman) + # Pre-store the table's own sky so the rim renders without the lazy + # PySwiss compute (no live HTTP from FTs). + room.sky_chart = _ROOM_SKY + room.save(update_fields=["sky_chart"]) hand = [ {"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"} for p in _HAND_POSITIONS[:hand_len] @@ -67,8 +89,8 @@ class SeedMapFeltTest(FunctionalTest): def _room_url(self, room): return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id}) - def test_seed_map_felt_opens_and_paints_dual_graph(self): - room = self._seed_room(hand_len=6) + def _open_seed_felt(self, room): + """Load the room, wait for SEED MAP to go live, click it open.""" self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) @@ -88,6 +110,10 @@ class SeedMapFeltTest(FunctionalTest): )) self.wait_for(_click_and_assert_open) + def test_seed_map_felt_opens_and_paints_dual_graph(self): + room = self._seed_room(hand_len=6) + self._open_seed_felt(room) + self.assertTrue( self.browser.find_element(By.ID, "id_seed_map_overlay").is_displayed() ) @@ -105,6 +131,58 @@ class SeedMapFeltTest(FunctionalTest): ), 1, ) + def test_seed_map_felt_rings_tessellation_with_room_sky_rim(self): + """Step 2's shared frame: the stripped sky wheel — canonical signs ring + + the ROOM's own planets, NOTHING location-bound (no houses/axes) and none + of the stripped chrome (element ring / centre disc / aspect web) — rings + the tessellation, which shrinks into the wheel's freed hub.""" + room = self._seed_room(hand_len=6) + self._open_seed_felt(room) + + # The rim paints: 12 canonical sign slices + the room sky's 3 planets. + self.wait_for(lambda: self.assertEqual( + self.browser.execute_script( + "return document.querySelectorAll('#id_seed_wheel_svg .nw-sign-group').length" + ), 12, + )) + self.assertEqual( + self.browser.execute_script( + "return document.querySelectorAll('#id_seed_wheel_svg .nw-planet-group').length" + ), 3, + ) + + # The strip: no element ring / centre disc / aspect web, and no + # location-bound layers (houses, ASC/MC axes) on a shared rim. + for cls in ["nw-elements", "nw-inner-disc", "nw-aspects", "nw-houses", "nw-axes"]: + self.assertEqual( + self.browser.execute_script( + f"return document.querySelectorAll('#id_seed_wheel_svg .{cls}').length" + ), 0, f"expected no .{cls} on the shared rim", + ) + + # The tessellation sits INSIDE the rim: the map svg is shrunk square + # into the wheel's hub (2 × hubR = 2 × 0.52 × 0.46 × wheel span) and + # carries the rimmed clip class. + map_w, map_h, col_w, col_h = self.browser.execute_script( + "const m = document.getElementById('id_seed_map_svg')," + " c = m.closest('.seed-map-col');" + "return [m.clientWidth, m.clientHeight, c.clientWidth, c.clientHeight];" + ) + self.assertEqual(map_w, map_h) # square + self.assertLess(map_w, min(col_w, col_h)) # inside the wheel + self.assertAlmostEqual( # = the freed hub + map_w, 2 * 0.52 * 0.46 * min(col_w, col_h), delta=2, + ) + self.assertIn("voronoi-map--rimmed", + self.browser.find_element(By.ID, "id_seed_map_svg").get_attribute("class")) + + # And the dual graph still paints inside the hub. + self.assertGreaterEqual( + self.browser.execute_script( + "return document.querySelectorAll('#id_seed_map_svg .voronoi-cell').length" + ), 1, + ) + def test_seed_map_felt_absent_until_hand_complete(self): room = self._seed_room(hand_len=5) # only 5 placed → NOT hand_complete self.create_pre_authenticated_session("founder@test.io") diff --git a/src/static/tests/SkyWheelSpec.js b/src/static/tests/SkyWheelSpec.js index 6e9cf1b..9af20e2 100644 --- a/src/static/tests/SkyWheelSpec.js +++ b/src/static/tests/SkyWheelSpec.js @@ -1033,3 +1033,116 @@ describe("SkyWheel — angle (ASC/MC) click tooltips", () => { expect(marsGroup.classList.contains("nw-planet--active")).toBe(true); }); }); + +// ── SkyWheel.drawRim — the SEED MAP's shared wheel rim ────────────────────── +// +// The STRIPPED wheel ringing the Voronoi tessellation (roadmap step 21, +// Step 2): the ROOM's own sky — planets only, since a virtual table has a +// convened TIME but no birth LOCATION (so houses/ASC/MC never exist on the +// shared rim) — around the canonical signs ring (asc=0 frame, identical for +// all six gamers). drawRim is a PURE renderer: no tooltips, no cycle, no +// transitions, and CRUCIALLY no singleton writes — the interactive saved +// wheel on the same page (_svg/_currentData/#id_sky_tooltip) must survive a +// rim draw untouched. + +describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { + + const ROOM_SKY = { + planets: { + Sun: { sign: "Pisces", degree: 338.4, retrograde: false }, + Moon: { sign: "Capricorn", degree: 295.1, retrograde: false }, + Mercury: { sign: "Aquarius", degree: 312.8, retrograde: true }, + }, + aspects: [], + }; + + let rimSvg, skySvg, tooltipEl; + + beforeEach(() => { + rimSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + rimSvg.setAttribute("id", "id_seed_wheel_svg"); + rimSvg.setAttribute("width", "400"); + rimSvg.setAttribute("height", "400"); + rimSvg.style.width = "400px"; + rimSvg.style.height = "400px"; + document.body.appendChild(rimSvg); + }); + + afterEach(() => { + rimSvg.remove(); + if (skySvg) { SkyWheel.clear(); skySvg.remove(); skySvg = null; } + if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } + }); + + it("R1: renders the canonical sign ring + the room sky's planet glyphs", () => { + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12); + expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(3); + expect(rimSvg.querySelector(".nw-outer-ring")).not.toBeNull(); + // Mercury's retrograde badge carries over to the rim. + expect(rimSvg.querySelector("[data-planet='Mercury'] .nw-rx")).not.toBeNull(); + }); + + it("R2: strips the element ring, centre disc, aspect web, houses and axes", () => { + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + ["nw-elements", "nw-inner-disc", "nw-aspects", "nw-houses", "nw-axes"] + .forEach((cls) => { + expect(rimSvg.querySelectorAll("." + cls).length) + .toBe(0, `expected no .${cls} on the shared rim`); + }); + }); + + it("R3: returns the hub geometry the tessellation slots into", () => { + const geo = SkyWheel.drawRim(rimSvg, ROOM_SKY); + + expect(geo.size).toBe(400); + expect(geo.cx).toBe(200); + expect(geo.cy).toBe(200); + expect(geo.r).toBeCloseTo(400 * 0.46, 6); + // The freed hub: just inside the planet band (planetR 0.59 − glyph 0.05). + expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6); + }); + + it("R4: places planets statically at their canonical (asc=0) angles — no entry transition", () => { + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + const sun = rimSvg.querySelector("[data-planet='Sun'] circle"); + const a = (-(338.4) - 180) * Math.PI / 180; // _toAngle(338.4, asc=0) + const r = 400 * 0.46; + expect(parseFloat(sun.getAttribute("cx"))).toBeCloseTo(200 + r * 0.59 * Math.cos(a), 1); + expect(parseFloat(sun.getAttribute("cy"))).toBeCloseTo(200 + r * 0.59 * Math.sin(a), 1); + }); + + it("R5: leaves the interactive singleton untouched — the sky felt's wheel keeps its svg + tooltip", () => { + skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + skySvg.setAttribute("id", "id_sky_svg"); + skySvg.style.width = "400px"; + skySvg.style.height = "400px"; + document.body.appendChild(skySvg); + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_sky_tooltip"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + SkyWheel.draw(skySvg, CONJUNCTION_CHART); + + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + // The tooltip controls draw() injected survive the rim draw. + expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull(); + // The singleton still points at the INTERACTIVE svg: clear() empties + // it, not the rim. + SkyWheel.clear(); + expect(skySvg.children.length).toBe(0); + expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12); + }); + + it("R6: renders the signs-only canonical frame when the room sky is absent", () => { + const geo = SkyWheel.drawRim(rimSvg, null); + + expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12); + expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0); + expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6); + }); +}); diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index 91ab400..4be6530 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -242,13 +242,29 @@ html.seed-open .seed-page.seed-page--room { height: 100%; } -// The dual-graph canvas fills the felt. Layers are styled by class so STEP 2 -// (card-driven territoriality) can recolour cells without touching layout. +// The col anchors the two stacked centred svgs (map + the wheel rim above it). +.seed-page--room .seed-map-col { + position: relative; +} + +// The dual-graph canvas. Step 2's rim: _paint() sizes this svg into the +// wheel's freed hub (inline 2×hubR px) — centred, so it stays concentric with +// the rim; the base 100%/100% is the no-rim fallback (Step-1 behaviour). +// Layers are styled by class so card-driven territoriality can recolour cells +// without touching layout. .seed-page--room svg.voronoi-map { display: block; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); width: 100%; height: 100%; + &.voronoi-map--rimmed { + clip-path: circle(50%); // round the hub — flush under the rim + } + .voronoi-cell { // territory base — filled cells fill: rgba(var(--priUser), 0.18); stroke: rgba(var(--secUser), 0.55); @@ -265,6 +281,19 @@ html.seed-open .seed-page.seed-page--room { } } +// The shared wheel rim — the stripped sky wheel (the ROOM's planets-only sky, +// canonical signs frame) drawn by SkyWheel.drawRim. Sits ABOVE the map svg in +// source order; pointer-events none so territory interaction falls through. +// The .nw-* ring/planet styling rules below apply to it unchanged. +.seed-page--room svg.seed-wheel { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + // Hide the z-130 position strip while the felt is up (mirrors sky/sea). html.seed-open .position-strip { visibility: hidden; diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index 1969f52..3472ee0 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -414,9 +414,9 @@ --terUser: var(--priBld); --quaUser: var(--priIce); --quiUser: var(--quaIce); - --sixUser: var(--terTrs); + --sixUser: var(--quaTrs); --sepUser: var(--terBld); - --octUser: var(--quiTrs); + --octUser: var(--sixTrs); --ninUser: var(--priMst); --decUser: var(--terMst); } diff --git a/src/static_src/tests/SkyWheelSpec.js b/src/static_src/tests/SkyWheelSpec.js index 6e9cf1b..9af20e2 100644 --- a/src/static_src/tests/SkyWheelSpec.js +++ b/src/static_src/tests/SkyWheelSpec.js @@ -1033,3 +1033,116 @@ describe("SkyWheel — angle (ASC/MC) click tooltips", () => { expect(marsGroup.classList.contains("nw-planet--active")).toBe(true); }); }); + +// ── SkyWheel.drawRim — the SEED MAP's shared wheel rim ────────────────────── +// +// The STRIPPED wheel ringing the Voronoi tessellation (roadmap step 21, +// Step 2): the ROOM's own sky — planets only, since a virtual table has a +// convened TIME but no birth LOCATION (so houses/ASC/MC never exist on the +// shared rim) — around the canonical signs ring (asc=0 frame, identical for +// all six gamers). drawRim is a PURE renderer: no tooltips, no cycle, no +// transitions, and CRUCIALLY no singleton writes — the interactive saved +// wheel on the same page (_svg/_currentData/#id_sky_tooltip) must survive a +// rim draw untouched. + +describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { + + const ROOM_SKY = { + planets: { + Sun: { sign: "Pisces", degree: 338.4, retrograde: false }, + Moon: { sign: "Capricorn", degree: 295.1, retrograde: false }, + Mercury: { sign: "Aquarius", degree: 312.8, retrograde: true }, + }, + aspects: [], + }; + + let rimSvg, skySvg, tooltipEl; + + beforeEach(() => { + rimSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + rimSvg.setAttribute("id", "id_seed_wheel_svg"); + rimSvg.setAttribute("width", "400"); + rimSvg.setAttribute("height", "400"); + rimSvg.style.width = "400px"; + rimSvg.style.height = "400px"; + document.body.appendChild(rimSvg); + }); + + afterEach(() => { + rimSvg.remove(); + if (skySvg) { SkyWheel.clear(); skySvg.remove(); skySvg = null; } + if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } + }); + + it("R1: renders the canonical sign ring + the room sky's planet glyphs", () => { + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12); + expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(3); + expect(rimSvg.querySelector(".nw-outer-ring")).not.toBeNull(); + // Mercury's retrograde badge carries over to the rim. + expect(rimSvg.querySelector("[data-planet='Mercury'] .nw-rx")).not.toBeNull(); + }); + + it("R2: strips the element ring, centre disc, aspect web, houses and axes", () => { + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + ["nw-elements", "nw-inner-disc", "nw-aspects", "nw-houses", "nw-axes"] + .forEach((cls) => { + expect(rimSvg.querySelectorAll("." + cls).length) + .toBe(0, `expected no .${cls} on the shared rim`); + }); + }); + + it("R3: returns the hub geometry the tessellation slots into", () => { + const geo = SkyWheel.drawRim(rimSvg, ROOM_SKY); + + expect(geo.size).toBe(400); + expect(geo.cx).toBe(200); + expect(geo.cy).toBe(200); + expect(geo.r).toBeCloseTo(400 * 0.46, 6); + // The freed hub: just inside the planet band (planetR 0.59 − glyph 0.05). + expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6); + }); + + it("R4: places planets statically at their canonical (asc=0) angles — no entry transition", () => { + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + const sun = rimSvg.querySelector("[data-planet='Sun'] circle"); + const a = (-(338.4) - 180) * Math.PI / 180; // _toAngle(338.4, asc=0) + const r = 400 * 0.46; + expect(parseFloat(sun.getAttribute("cx"))).toBeCloseTo(200 + r * 0.59 * Math.cos(a), 1); + expect(parseFloat(sun.getAttribute("cy"))).toBeCloseTo(200 + r * 0.59 * Math.sin(a), 1); + }); + + it("R5: leaves the interactive singleton untouched — the sky felt's wheel keeps its svg + tooltip", () => { + skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + skySvg.setAttribute("id", "id_sky_svg"); + skySvg.style.width = "400px"; + skySvg.style.height = "400px"; + document.body.appendChild(skySvg); + tooltipEl = document.createElement("div"); + tooltipEl.id = "id_sky_tooltip"; + tooltipEl.style.display = "none"; + document.body.appendChild(tooltipEl); + SkyWheel.draw(skySvg, CONJUNCTION_CHART); + + SkyWheel.drawRim(rimSvg, ROOM_SKY); + + // The tooltip controls draw() injected survive the rim draw. + expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull(); + // The singleton still points at the INTERACTIVE svg: clear() empties + // it, not the rim. + SkyWheel.clear(); + expect(skySvg.children.length).toBe(0); + expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12); + }); + + it("R6: renders the signs-only canonical frame when the room sky is absent", () => { + const geo = SkyWheel.drawRim(rimSvg, null); + + expect(rimSvg.querySelectorAll(".nw-sign-group").length).toBe(12); + expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0); + expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6); + }); +}); diff --git a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html index 7a57886..2546d5c 100644 --- a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html +++ b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html @@ -2,25 +2,41 @@ {# SEED MAP felt — d3-delaunay dual graph (Voronoi cells = territory + Delaunay #} {# edges = adjacency) on a my_sea-style --duoUser felt filling .room-hex-pane. #} {# Inline sibling of CAST SKY (_sky_overlay) / DRAW SEA (_sea_overlay). Opens on #} -{# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; card-driven #} -{# seeding (the 6 Celtic-Cross cards) is STEP 2. See project_voronoi_spec. #} -
+{# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; Step 2's first #} +{# piece is live: the SHARED wheel rim — the ROOM's own sky (planets only; no #} +{# location => no houses/axes), one canonical frame for all six gamers — rings #} +{# the tessellation, which _paint() sizes into the wheel's freed hub. #} +{# Card-driven seeding (the 6 Celtic-Cross cards) is the next piece. #} +{# See project_voronoi_spec. #} +
+
+{# NO sky-wheel include here — _sky_overlay.html (always rendered in SKY_SELECT, #} +{# a strictly looser gate than this felt's) already loads it, and a second #} +{# top-level `const SkyWheel` declaration throws. #}