From 32db2035435731c11c38bd9ecb88f3c9cb82eb51 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 10 Jun 2026 12:30:16 -0400 Subject: [PATCH] =?UTF-8?q?Set=20the=20Game=20Clock=20=E2=80=94=20ephemeri?= =?UTF-8?q?s=20narrowing=20(hard=20CSP):=20placements=20shrink=20the=20REA?= =?UTF-8?q?L=20date=20window;=20narrowed=20range=20prints=20below=20the=20?= =?UTF-8?q?prompt;=20unreachable=20signs=20dim=20+=20reject=20=E2=80=94=20?= =?UTF-8?q?TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PySwiss gains its FIRST reverse lookup: GET /api/windows/?placements= Uranus:Aquarius,…&next=Saturn — sign_windows (per-planet stride scan + bisection edge refine to the hour) folds thru narrowed_windows (slowest planet first, each scan restricted to the prior intersection so the fast bodies only ever scan slivers) over the game window (settings GAME_WINDOW_START/END, default 1781-03-13 — Uranus's discovery — → 2100-12-31, the snapshot span); present_signs reports the next planet's reachable signs. Self-validating UTs (every window forward-checked at midpoint + edges) + 8 API ITs - epic clock_windows endpoint (lazy, table_sky-shaped — room views stay HTTP-free) proxies the lookup, cached per room+placements (six felts polling one ritual state = ONE upstream call; failures cached 60s); fails OPEN {available:false} when PySwiss is unreachable - place_clock_planet enforces the HARD constraint: a sign outside the narrowed windows' reach → 409 sign_unreachable; fail-open w.o PySwiss (the ritual never bricks on microservice downtime); PlaceClockPlanet ITs sever PySwiss in setUp so the turn walk stays deterministic against a live local service - felt: #id_clock_windows readout below the prompt for ALL viewers — "1995-04-01 → 1998-04-17 · 2 windows" — fetched at parse + after own placement + on every clock_placement broadcast; drawRim opts gain allowedSigns → unreachable wedges .nw-sign--blocked (dimmed, inert, no handlers); SkyWheelSpec R10/R11 - SeedMapClockNarrowingTest FT stubs PySwiss in-process (real proxy, real gating): readout renders, blocked Aries won't place, allowed Pisces lands Saturn, readout re-narrows [[project-voronoi-spec]] Co-Authored-By: Claude Fable 5 --- pyswiss/apps/charts/calc.py | 103 +++++++++++ .../tests/integrated/test_windows_api.py | 81 +++++++++ .../apps/charts/tests/unit/test_windows.py | 113 ++++++++++++ pyswiss/apps/charts/urls.py | 1 + pyswiss/apps/charts/views.py | 54 ++++++ pyswiss/core/settings.py | 6 + src/apps/epic/tests/integrated/test_views.py | 139 ++++++++++++++ src/apps/epic/urls.py | 1 + src/apps/epic/views.py | 70 ++++++++ .../static/apps/gameboard/sky-wheel.js | 15 +- .../test_game_room_seed_map.py | 170 +++++++++++++++++- src/static/tests/SkyWheelSpec.js | 32 ++++ src/static_src/scss/_sky.scss | 24 +++ src/static_src/tests/SkyWheelSpec.js | 32 ++++ .../_partials/_seed_map_overlay.html | 54 +++++- 15 files changed, 888 insertions(+), 7 deletions(-) create mode 100644 pyswiss/apps/charts/tests/integrated/test_windows_api.py create mode 100644 pyswiss/apps/charts/tests/unit/test_windows.py diff --git a/pyswiss/apps/charts/calc.py b/pyswiss/apps/charts/calc.py index e898f4e..8f9f7f8 100644 --- a/pyswiss/apps/charts/calc.py +++ b/pyswiss/apps/charts/calc.py @@ -77,6 +77,109 @@ def get_planet_positions(jd): return planets +# ── Reverse lookup — sign windows (Set the Game Clock narrowing) ───────────── +# +# Forward calculation answers "where is the planet at time T"; the Game Clock +# ritual needs the REVERSE — "when does planet P reside in sign S" — so each +# placement can narrow the real date window the ritual still resolves to. +# +# Scan strides (days) sit well under each planet's shortest typical sign +# residence. Retrograde re-dips briefer than the stride can be missed — that +# is CONSERVATIVE: a missed sliver only narrows the reachable window, it never +# admits a false moment (every returned window is forward-verified by its +# construction: sampled in-sign, edges bisected to the true crossing). + +SIGN_SCAN_STRIDES = { + 'Moon': 0.5, 'Sun': 5.0, 'Mercury': 2.0, 'Venus': 2.0, 'Mars': 5.0, + 'Jupiter': 15.0, 'Saturn': 30.0, 'Uranus': 60.0, 'Neptune': 60.0, + 'Pluto': 60.0, +} +EDGE_PRECISION_DAYS = 1.0 / 24.0 # bisect sign crossings to the hour + + +def get_planet_sign(jd, planet): + pos, _ = swe.calc_ut(jd, PLANET_CODES[planet], swe.FLG_SWIEPH) + return get_sign(pos[0]) + + +def jd_to_iso(jd): + y, m, d, h = swe.revjul(jd) + hh = int(h) + mm = int(round((h - hh) * 60)) + if mm == 60: + hh, mm = hh + 1, 0 + return f'{y:04d}-{m:02d}-{d:02d}T{hh:02d}:{mm:02d}:00Z' + + +def _bisect_edge(planet, sign, jd_match, jd_other): + """Refine the sign crossing between a jd where `planet` IS in `sign` and + one where it is not, to EDGE_PRECISION_DAYS. Returns the match-side jd.""" + while abs(jd_other - jd_match) > EDGE_PRECISION_DAYS: + mid = (jd_match + jd_other) / 2 + if get_planet_sign(mid, planet) == sign: + jd_match = mid + else: + jd_other = mid + return jd_match + + +def sign_windows(planet, sign, windows): + """The sub-windows of `windows` — a list of (jd_start, jd_end) — where + `planet` resides in `sign`. Stride scan + bisection edge refine.""" + stride = SIGN_SCAN_STRIDES[planet] + out = [] + for lo, hi in windows: + if hi <= lo: + continue + samples = [] + jd = lo + while jd < hi: + samples.append(jd) + jd += stride + samples.append(hi) + + run_start = None + prev = None + for s in samples: + in_sign = get_planet_sign(s, planet) == sign + if in_sign and run_start is None: + run_start = lo if prev is None else _bisect_edge(planet, sign, s, prev) + elif not in_sign and run_start is not None: + out.append((run_start, _bisect_edge(planet, sign, prev, s))) + run_start = None + prev = s + if run_start is not None: + out.append((run_start, hi)) + return out + + +def narrowed_windows(placements, windows): + """Fold each placement's sign residences into the intersection. Slowest + planets first (fewest fragments); each scan is restricted to the windows + the prior placements already narrowed to, so the fast bodies only ever + scan slivers.""" + for planet, sign in sorted( + placements.items(), key=lambda kv: -SIGN_SCAN_STRIDES[kv[0]]): + if not windows: + break + windows = sign_windows(planet, sign, windows) + return windows + + +def present_signs(planet, windows): + """The set of signs `planet` occupies at any point within `windows` — + sampled at the scan stride plus both edges (presence needs no bisection).""" + stride = SIGN_SCAN_STRIDES[planet] + signs = set() + for lo, hi in windows: + jd = lo + while jd < hi: + signs.add(get_planet_sign(jd, planet)) + jd += stride + signs.add(get_planet_sign(hi, planet)) + return signs + + def get_element_counts(planets): sign_counts = {s: 0 for s in SIGNS} sign_planets = {s: [] for s in SIGNS} diff --git a/pyswiss/apps/charts/tests/integrated/test_windows_api.py b/pyswiss/apps/charts/tests/integrated/test_windows_api.py new file mode 100644 index 0000000..2e722e1 --- /dev/null +++ b/pyswiss/apps/charts/tests/integrated/test_windows_api.py @@ -0,0 +1,81 @@ +""" +Integration tests for GET /api/windows/ — the reverse ephemeris lookup +(Set the Game Clock narrowing, EarthmanRPG roadmap step 21). + +The endpoint folds `placements` (Planet:Sign pairs) into the intersected date +windows where every placement holds simultaneously, bounded by the game window +(settings GAME_WINDOW_START/END — defaults span the precomputed snapshot +range, 1781-03-13 → 2100-12-31), and reports which signs the `next` planet can +reach within them. Tests override the game window to a tight decade so the +real ephemeris scan stays fast + deterministic. + +Run: + pyswiss/.venv/Scripts/python pyswiss/manage.py test apps.charts +""" +from django.test import SimpleTestCase, override_settings + + +@override_settings(GAME_WINDOW_START='1990-01-01', GAME_WINDOW_END='2000-01-01') +class WindowsApiTest(SimpleTestCase): + + def test_empty_placements_returns_the_full_game_window(self): + response = self.client.get('/api/windows/') + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data['windows']), 1) + self.assertTrue(data['windows'][0]['start'].startswith('1990-01-01')) + self.assertTrue(data['windows'][0]['end'].startswith('2000-01-01')) + self.assertAlmostEqual(data['days'], 3652, delta=2) + + def test_placements_narrow_the_windows(self): + response = self.client.get( + '/api/windows/', + {'placements': 'Uranus:Aquarius,Saturn:Pisces'}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertGreaterEqual(len(data['windows']), 1) + self.assertLess(data['days'], 500) + for w in data['windows']: + self.assertLess(w['start'], w['end']) + + def test_next_reports_reachable_signs(self): + response = self.client.get( + '/api/windows/', + {'placements': 'Saturn:Pisces', 'next': 'Jupiter'}, + ) + self.assertEqual(response.status_code, 200) + nxt = response.json()['next'] + self.assertEqual(nxt['planet'], 'Jupiter') + self.assertEqual(len(nxt['signs']), 12) + reachable = [s for s, ok in nxt['signs'].items() if ok] + # Jupiter (1-year residence) reaches SOME but not ALL signs inside + # Saturn's ~3-year Pisces windows. + self.assertGreater(len(reachable), 0) + self.assertLess(len(reachable), 12) + + def test_unreachable_placement_yields_empty_windows(self): + response = self.client.get( + '/api/windows/', {'placements': 'Saturn:Leo', 'next': 'Jupiter'}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['windows'], []) + self.assertEqual(data['days'], 0) + self.assertEqual( + [s for s, ok in data['next']['signs'].items() if ok], []) + + def test_invalid_planet_400(self): + self.assertEqual(self.client.get( + '/api/windows/', {'placements': 'Vulcan:Aries'}).status_code, 400) + + def test_invalid_sign_400(self): + self.assertEqual(self.client.get( + '/api/windows/', {'placements': 'Saturn:Notasign'}).status_code, 400) + + def test_invalid_next_400(self): + self.assertEqual(self.client.get( + '/api/windows/', {'next': 'Vulcan'}).status_code, 400) + + def test_malformed_placements_400(self): + self.assertEqual(self.client.get( + '/api/windows/', {'placements': 'SaturnPisces'}).status_code, 400) diff --git a/pyswiss/apps/charts/tests/unit/test_windows.py b/pyswiss/apps/charts/tests/unit/test_windows.py new file mode 100644 index 0000000..8a1e136 --- /dev/null +++ b/pyswiss/apps/charts/tests/unit/test_windows.py @@ -0,0 +1,113 @@ +""" +Unit tests for the reverse ephemeris lookup — sign_windows / narrowed_windows / +present_signs (Set the Game Clock narrowing, EarthmanRPG roadmap step 21). + +Unlike test_calc.py these DO drive the Swiss Ephemeris (Moshier fallback when +data files are absent — 0.1° precision, far inside the ±1-day tolerances +here). The assertions are largely SELF-VALIDATING: rather than hardcoding +astrological history, each returned window is forward-checked — its midpoint +must actually carry the planet in the claimed sign, and its edges must be true +sign boundaries. + +Run: + pyswiss/.venv/Scripts/python pyswiss/manage.py test apps.charts +""" +from datetime import datetime, timezone + +from django.test import SimpleTestCase + +import swisseph as swe + +from apps.charts.calc import ( + PLANET_CODES, + get_julian_day, + get_sign, + narrowed_windows, + present_signs, + set_ephe_path, + sign_windows, +) + + +def _jd(iso): + return get_julian_day(datetime.fromisoformat(iso).replace(tzinfo=timezone.utc)) + + +def _sign_at(jd, planet): + pos, _ = swe.calc_ut(jd, PLANET_CODES[planet], swe.FLG_SWIEPH) + return get_sign(pos[0]) + + +class SignWindowsTest(SimpleTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + set_ephe_path() + + def test_sun_in_aries_2020(self): + wins = sign_windows('Sun', 'Aries', [(_jd('2020-01-01'), _jd('2020-12-31'))]) + self.assertEqual(len(wins), 1) + a, b = wins[0] + # Ingress at the March equinox, egress ~30 days later. + self.assertAlmostEqual(a, _jd('2020-03-20'), delta=1.0) + self.assertAlmostEqual(b, _jd('2020-04-19'), delta=1.0) + + def test_window_edges_are_true_sign_boundaries(self): + lo, hi = _jd('1990-01-01'), _jd('2000-01-01') + wins = sign_windows('Saturn', 'Pisces', [(lo, hi)]) + # Saturn's mid-90s Pisces residence, split by the retrograde dip back + # into Aquarius — at least one window, every one self-consistent. + self.assertGreaterEqual(len(wins), 1) + for a, b in wins: + self.assertLess(a, b) + self.assertEqual(_sign_at((a + b) / 2, 'Saturn'), 'Pisces') + if a > lo + 1: # true boundary (not clipped by the scan bound) + self.assertNotEqual(_sign_at(a - 0.2, 'Saturn'), 'Pisces') + if b < hi - 1: + self.assertNotEqual(_sign_at(b + 0.2, 'Saturn'), 'Pisces') + + def test_edges_refined_to_the_hour(self): + wins = sign_windows('Sun', 'Aries', [(_jd('2020-01-01'), _jd('2020-12-31'))]) + a, b = wins[0] + # Within 1/24 day of the crossing on each side. + self.assertEqual(_sign_at(a + 1 / 24, 'Sun'), 'Aries') + self.assertNotEqual(_sign_at(a - 1 / 24, 'Sun'), 'Aries') + self.assertEqual(_sign_at(b - 1 / 24, 'Sun'), 'Aries') + self.assertNotEqual(_sign_at(b + 1 / 24, 'Sun'), 'Aries') + + def test_narrowed_windows_intersect_all_placements(self): + base = [(_jd('1990-01-01'), _jd('2000-01-01'))] + wins = narrowed_windows({'Uranus': 'Aquarius', 'Saturn': 'Pisces'}, base) + self.assertGreaterEqual(len(wins), 1) + for a, b in wins: + mid = (a + b) / 2 + self.assertEqual(_sign_at(mid, 'Uranus'), 'Aquarius') + self.assertEqual(_sign_at(mid, 'Saturn'), 'Pisces') + total = sum(b - a for a, b in wins) + # Vastly narrower than the 10-year base, but a real window. + self.assertGreater(total, 30) + self.assertLess(total, 500) + + def test_narrowed_windows_empty_when_unreachable(self): + # Saturn resided in Leo in the late 70s and mid 00s — never in the 90s. + wins = narrowed_windows( + {'Saturn': 'Leo'}, [(_jd('1990-01-01'), _jd('2000-01-01'))]) + self.assertEqual(wins, []) + + def test_no_placements_keeps_the_base_window(self): + base = [(_jd('1990-01-01'), _jd('2000-01-01'))] + self.assertEqual(narrowed_windows({}, base), base) + + def test_present_signs_lists_reachable_signs_only(self): + signs = present_signs( + 'Saturn', [(_jd('1994-06-01'), _jd('1996-06-01'))]) + # Pisces until the 1996 Aries ingress — exactly two reachable signs. + self.assertEqual(signs, {'Pisces', 'Aries'}) + + def test_moon_windows_inside_a_narrow_window(self): + # The end-game shape: by the Moon's turn the windows are days wide. + wins = sign_windows('Moon', 'Cancer', [(_jd('2020-06-01'), _jd('2020-07-01'))]) + self.assertEqual(len(wins), 1) + a, b = wins[0] + self.assertAlmostEqual(b - a, 2.4, delta=0.5) # ~2.4-day residence + self.assertEqual(_sign_at((a + b) / 2, 'Moon'), 'Cancer') diff --git a/pyswiss/apps/charts/urls.py b/pyswiss/apps/charts/urls.py index 005a36f..28724d6 100644 --- a/pyswiss/apps/charts/urls.py +++ b/pyswiss/apps/charts/urls.py @@ -4,5 +4,6 @@ from . import views urlpatterns = [ path('chart/', views.chart, name='chart'), path('charts/', views.charts_list, name='charts_list'), + path('windows/', views.windows, name='windows'), path('tz/', views.timezone_lookup, name='timezone_lookup'), ] diff --git a/pyswiss/apps/charts/views.py b/pyswiss/apps/charts/views.py index 5b1ef84..c2fbed9 100644 --- a/pyswiss/apps/charts/views.py +++ b/pyswiss/apps/charts/views.py @@ -5,12 +5,19 @@ from timezonefinder import TimezoneFinder import swisseph as swe +from django.conf import settings as django_settings + from .calc import ( DEFAULT_HOUSE_SYSTEM, + PLANET_CODES, + SIGNS, calculate_aspects, get_element_counts, get_julian_day, get_planet_positions, + jd_to_iso, + narrowed_windows, + present_signs, set_ephe_path, ) from .models import EphemerisSnapshot @@ -68,6 +75,53 @@ def chart(request): }) +def windows(request): + """GET /api/windows/ — REVERSE ephemeris lookup (Set the Game Clock). + + Query params: + placements — comma list of Planet:Sign pairs (may be absent/empty) + next — planet name; report which signs it can reach in the windows + + Folds each placement's sign residences into the intersected date windows + where ALL placements hold simultaneously, bounded by the game window + (settings GAME_WINDOW_START/END — defaults span the precomputed snapshot + range: 1781-03-13, Uranus's discovery year, → 2100-12-31). + + Returns {windows: [{start, end}…], days, next?: {planet, signs: {sign: + bool ×12}}} 200 · 400 on an unknown planet/sign or malformed pair. + """ + placements = {} + for pair in [p for p in request.GET.get('placements', '').split(',') if p]: + planet, sep, sign = pair.partition(':') + if not sep or planet not in PLANET_CODES or sign not in SIGNS: + return HttpResponse(status=400) + placements[planet] = sign + next_planet = request.GET.get('next') or None + if next_planet is not None and next_planet not in PLANET_CODES: + return HttpResponse(status=400) + + set_ephe_path() + start = getattr(django_settings, 'GAME_WINDOW_START', '1781-03-13') + end = getattr(django_settings, 'GAME_WINDOW_END', '2100-12-31') + base = [( + get_julian_day(datetime.fromisoformat(start).replace(tzinfo=timezone.utc)), + get_julian_day(datetime.fromisoformat(end).replace(tzinfo=timezone.utc)), + )] + wins = narrowed_windows(placements, base) + + payload = { + 'windows': [{'start': jd_to_iso(a), 'end': jd_to_iso(b)} for a, b in wins], + 'days': round(sum(b - a for a, b in wins), 2), + } + if next_planet: + reachable = present_signs(next_planet, wins) + payload['next'] = { + 'planet': next_planet, + 'signs': {s: s in reachable for s in SIGNS}, + } + return JsonResponse(payload) + + _tf = TimezoneFinder() diff --git a/pyswiss/core/settings.py b/pyswiss/core/settings.py index dc077ef..41d235f 100644 --- a/pyswiss/core/settings.py +++ b/pyswiss/core/settings.py @@ -47,3 +47,9 @@ SWISSEPH_PATH = os.environ.get( 'SWISSEPH_PATH', r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe', ) + +# The game window — the date bounds the Set-the-Game-Clock ritual can resolve +# within (/api/windows/ reverse lookup). Defaults span the precomputed +# snapshot range: 1781-03-13 (Uranus's discovery) → 2100-12-31. +GAME_WINDOW_START = os.environ.get('GAME_WINDOW_START', '1781-03-13') +GAME_WINDOW_END = os.environ.get('GAME_WINDOW_END', '2100-12-31') diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index da3eb26..d8688d1 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -3051,6 +3051,17 @@ class PlaceClockPlanetTest(TestCase): ) self.url = reverse("epic:place_clock_planet", kwargs={"room_id": self.room.id}) self.client.force_login(self.p6) + # Sever PySwiss: narrowing fails OPEN, so these tests pin the TURN + # machinery deterministically — with a live local PySwiss the real + # ephemeris would (correctly) 409 the fixture's fantasy sign walk. + # The narrowing gate itself is pinned by the two tests that mock + # _clock_windows_data, and end-to-end by SeedMapClockNarrowingTest. + patcher = patch( + "apps.epic.views.http_requests.get", + side_effect=Exception("no PySwiss in ITs"), + ) + patcher.start() + self.addCleanup(patcher.stop) def _post(self, planet="Uranus", sign="Aquarius"): return self.client.post(self.url, {"planet": planet, "sign": sign}) @@ -3169,6 +3180,34 @@ class PlaceClockPlanetTest(TestCase): # Ritual complete — no circle holds a turn any more. self.assertEqual(self._post(planet="Moon", sign="Leo").status_code, 403) + def test_unreachable_sign_rejected_409(self): + """HARD ephemeris constraint (decision A in project_voronoi_spec): even + on the right turn, a sign the planet cannot reach within the narrowed + windows is rejected — placements must correspond to a REAL moment.""" + with patch("apps.epic.views._clock_windows_data") as mock_windows: + mock_windows.return_value = { + "available": True, "allowed_signs": ["Pisces"], + "windows": [], "days": 0, + } + response = self._post(planet="Uranus", sign="Aquarius") + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"], "sign_unreachable") + self.room.refresh_from_db() + self.assertEqual(self.room.clock_placements, {}) + + def test_narrowing_unavailable_fails_open(self): + """PySwiss down ⇒ the ritual must not brick: with no narrowing data + (allowed_signs None) any valid sign lands.""" + with patch("apps.epic.views._clock_windows_data") as mock_windows: + mock_windows.return_value = { + "available": False, "allowed_signs": None, + "windows": None, "days": None, + } + response = self._post() + self.assertEqual(response.status_code, 200) + self.room.refresh_from_db() + self.assertEqual(self.room.clock_placements, {"Uranus": "Aquarius"}) + def test_placement_broadcasts_clock_placement(self): """A landed placement broadcasts to the room group — ONE shared map per room, updating asynchronously on every open felt — carrying the new @@ -3187,6 +3226,96 @@ class PlaceClockPlanetTest(TestCase): mock_notify.assert_not_called() +class ClockWindowsViewTest(TestCase): + """clock_windows — the ephemeris-narrowing proxy ([[project-voronoi-spec]]): + each placement narrows the REAL date windows the ritual can still resolve + to. PySwiss /api/windows/ (the reverse lookup) is proxied lazily — room + views stay HTTP-free — and cached per placements; when PySwiss is + unreachable the payload fails OPEN ({available: false}) so the ritual + never bricks on microservice downtime.""" + + def setUp(self): + self.earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + self.gamer = User.objects.create(email="windows@clock.io") + self.room = Room.objects.create( + name="Windows", owner=self.gamer, table_status=Room.SKY_SELECT + ) + slot = self.room.gate_slots.get(slot_number=5) + slot.gamer = self.gamer + slot.status = GateSlot.FILLED + slot.save() + TableSeat.objects.create( + room=self.room, gamer=self.gamer, slot_number=5, role="NC", + deck_variant=self.earthman, + ) + self.room.clock_placements = {"Uranus": "Aquarius"} + self.room.gate_status = Room.OPEN + self.room.save() + self.url = reverse("epic:clock_windows", kwargs={"room_id": self.room.id}) + self.client.force_login(self.gamer) + + def _mock_pyswiss(self, mock_get): + signs = {s: s == "Pisces" for s in [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", + "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]} + mock_get.return_value.json.return_value = { + "windows": [ + {"start": "1995-04-01T08:00:00Z", "end": "1995-06-09T03:00:00Z"}, + {"start": "1996-01-12T07:00:00Z", "end": "1998-04-17T22:00:00Z"}, + ], + "days": 880.0, + "next": {"planet": "Saturn", "signs": signs}, + } + mock_get.return_value.raise_for_status.return_value = None + + def test_proxies_pyswiss_and_shapes_the_narrowing(self): + with patch("apps.epic.views.http_requests.get") as mock_get: + self._mock_pyswiss(mock_get) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data["available"]) + self.assertEqual(len(data["windows"]), 2) + self.assertEqual(data["windows"][0]["start"], "1995-04-01T08:00:00Z") + self.assertEqual(data["days"], 880.0) + self.assertEqual(data["next_planet"], "Saturn") + self.assertEqual(data["next_slot"], 5) + self.assertEqual(data["allowed_signs"], ["Pisces"]) + # The proxy asked PySwiss for exactly the room's placements + the + # roster's next planet. + params = mock_get.call_args.kwargs["params"] + self.assertEqual(params["placements"], "Uranus:Aquarius") + self.assertEqual(params["next"], "Saturn") + + def test_unreachable_pyswiss_fails_open(self): + with patch("apps.epic.views.http_requests.get", + side_effect=Exception("down")): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertFalse(data["available"]) + self.assertIsNone(data["windows"]) + self.assertIsNone(data["allowed_signs"]) + self.assertEqual(data["next_planet"], "Saturn") + self.assertEqual(data["next_slot"], 5) + + def test_cached_per_placements(self): + """Six felts polling the same ritual state must cost ONE PySwiss call.""" + with patch("apps.epic.views.http_requests.get") as mock_get: + self._mock_pyswiss(mock_get) + self.client.get(self.url) + self.client.get(self.url) + self.assertEqual(mock_get.call_count, 1) + + def test_unseated_gamer_403(self): + outsider = User.objects.create(email="out@windows.io") + self.client.force_login(outsider) + self.assertEqual(self.client.get(self.url).status_code, 403) + + # ── tarot_deal ──────────────────────────────────────────────────────────────── class TarotDealViewTest(TestCase): @@ -4949,6 +5078,16 @@ class PickSeaUnifiedFeltTest(TestCase): content = self.client.get(self.url).content.decode() self.assertIn('data-clock-slot="1"', content) + def test_seed_map_overlay_carries_windows_readout(self): + """Ephemeris narrowing: the felt carries the #id_clock_windows readout + (filled lazily from epic:clock_windows — the narrowed date range the + ritual can still resolve to, printed below the placement prompt) + the + endpoint URL the overlay JS fetches.""" + self._complete_hand() + content = self.client.get(self.url).content.decode() + self.assertIn('id="id_clock_windows"', content) + self.assertIn("data-clock-windows-url", content) + def test_seed_map_overlay_affordance_reaches_circle_1_for_the_moon(self): """Turn progression is reload-safe at the FAR end of the roster: with the five outer planets down, circle 1 (the founder PC) holds the final diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 3dc8f99..bcd7f33 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ path('room//sky/delete', views.sky_delete, name='sky_delete'), path('room//sky/table', views.table_sky, name='table_sky'), path('room//clock/place', views.place_clock_planet, name='place_clock_planet'), + path('room//clock/windows', views.clock_windows, name='clock_windows'), 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 1f011c6..b56358c 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,3 +1,4 @@ +import hashlib import json import zoneinfo from datetime import datetime, timedelta @@ -7,6 +8,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.conf import settings from django.contrib.auth.decorators import login_required +from django.core.cache import cache from django.db import transaction from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import redirect, render @@ -1910,6 +1912,67 @@ _ZODIAC_SIGNS = { } +def _clock_windows_data(room): + """The NARROWED ephemeris windows for the room's current placements + the + signs the next planet can reach within them — the Game Clock's hard CSP + (decision A, [[project-voronoi-spec]]). Proxies PySwiss /api/windows/ (the + reverse lookup) and caches per placements, so six felts polling the same + ritual state cost ONE upstream call. When PySwiss is unreachable the + payload fails OPEN ({available: False, allowed_signs: None}) — the ritual + must not brick on microservice downtime — and the failure is cached + briefly so a down service isn't hammered.""" + placements = room.clock_placements or {} + next_planet = next((p for p in CLOCK_ORDER if p not in placements), None) + fingerprint = hashlib.md5( + json.dumps(placements, sort_keys=True).encode()).hexdigest() + cache_key = f"clock_windows_{room.id}_{fingerprint}" + data = cache.get(cache_key) + if data is None: + params = {"placements": ",".join( + f"{p}:{s}" for p, s in placements.items())} + if next_planet: + params["next"] = next_planet + try: + resp = http_requests.get( + settings.PYSWISS_URL + '/api/windows/', params=params, timeout=10, + ) + resp.raise_for_status() + j = resp.json() + signs = (j.get("next") or {}).get("signs") or {} + data = { + "available": True, + "windows": j.get("windows") or [], + "days": j.get("days"), + "allowed_signs": ( + [s for s, ok in signs.items() if ok] if next_planet else None + ), + } + cache.set(cache_key, data, 24 * 3600) + except Exception: + data = {"available": False, "windows": None, "days": None, + "allowed_signs": None} + cache.set(cache_key, data, 60) + data = dict(data) + data["next_planet"] = next_planet + data["next_slot"] = CLOCK_SLOT_BY_PLANET.get(next_planet) + return data + + +@login_required +def clock_windows(request, room_id): + """Set the Game Clock — the narrowed window readout + sign gating data the + SEED MAP felt fetches lazily (on open + on every clock_placement + broadcast; room views stay HTTP-free, same shape as table_sky). + + Returns {available, windows, days, next_planet, next_slot, allowed_signs} + 200 · 403 not seated. + """ + room = Room.objects.get(id=room_id) + if _canonical_user_seat(room, request.user) is None: + return HttpResponse(status=403) + return JsonResponse(_clock_windows_data(room)) + + def _clock_placeable_for(seat, placements): """The planet `seat`'s gamer may place RIGHT NOW — its circle's planet, when it's that planet's turn (every earlier planet placed, this one not yet) — or @@ -1948,6 +2011,13 @@ def place_clock_planet(request, room_id): placements = dict(room.clock_placements or {}) if planet != _clock_placeable_for(seat, placements): return HttpResponse(status=403) + # HARD ephemeris constraint (decision A): the sign must be reachable + # within the windows the prior placements narrowed to — placements must + # correspond to a REAL moment. Fails open (allowed_signs None) when + # PySwiss is unreachable. + allowed_signs = _clock_windows_data(room).get("allowed_signs") + if allowed_signs is not None and sign not in allowed_signs: + return JsonResponse({"error": "sign_unreachable"}, status=409) placements[planet] = sign room.clock_placements = placements room.save(update_fields=["clock_placements"]) diff --git a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js index bb7ca8a..cc8edef 100644 --- a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js @@ -1462,9 +1462,11 @@ const SkyWheel = (() => { * * opts (optional) — Set the Game Clock placement mode: * {placeable: 'Uranus', onPickSign: fn(signName)} turns the sign wedges - * into placement targets (.nw-sign--placeable + cursor); clicking one calls - * onPickSign(name). Still singleton-pure — the handler is local, no module - * state is written. + * into placement targets (.nw-sign--placeable + cursor); tapping one calls + * onPickSign(name). {allowedSigns: ['Pisces', …]} restricts the targets to + * the ephemeris-reachable signs — the rest dim (.nw-sign--blocked), inert; + * omitted/null = no narrowing, all 12 placeable. Still singleton-pure — + * the handler is local, no module state is written. * * 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 + @@ -1512,7 +1514,12 @@ const SkyWheel = (() => { // scroll/drag intent either fires pointercancel (no up) or drifts past // the slop radius. Mice fire the same pair, so desktop needs no click // handler (binding one would double-pick). - if (opts.placeable) { + if (opts.placeable && opts.allowedSigns && + opts.allowedSigns.indexOf(sign.name) === -1) { + // Ephemeris narrowing (hard CSP): the planet cannot reach this sign + // within the narrowed windows — dim the wedge, bind nothing. + slice.classed('nw-sign--blocked', true); + } else if (opts.placeable) { slice.classed('nw-sign--placeable', true).style('cursor', 'pointer'); const pick = function (event) { event.stopPropagation(); diff --git a/src/functional_tests/test_game_room_seed_map.py b/src/functional_tests/test_game_room_seed_map.py index d269274..41b59cc 100644 --- a/src/functional_tests/test_game_room_seed_map.py +++ b/src/functional_tests/test_game_room_seed_map.py @@ -20,10 +20,14 @@ 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. """ +import json import os +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse from django.conf import settings -from django.test import tag +from django.test import override_settings, tag from django.urls import reverse from django.utils import timezone from selenium import webdriver @@ -385,6 +389,170 @@ class SeedMapClockTest(FunctionalTest): self.browser.find_elements(By.ID, "id_clock_prompt"), [])) +def _signs_dict(*allowed): + signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", + "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] + return {s: s in allowed for s in signs} + + +# Canned PySwiss /api/windows/ payloads — keyed on how many placements the +# query carries. One placement (Uranus) → two windows, Saturn reachable only +# in Pisces; two placements (Uranus + Saturn) → one narrower window. +_STUB_WINDOWS_AFTER_URANUS = { + "windows": [ + {"start": "1995-04-01T08:00:00Z", "end": "1995-06-09T03:00:00Z"}, + {"start": "1996-01-12T07:00:00Z", "end": "1998-04-17T22:00:00Z"}, + ], + "days": 880.0, + "next": {"planet": "Saturn", "signs": _signs_dict("Pisces")}, +} +_STUB_WINDOWS_AFTER_SATURN = { + "windows": [ + {"start": "1996-01-12T07:00:00Z", "end": "1996-04-07T11:00:00Z"}, + ], + "days": 86.2, + "next": {"planet": "Jupiter", "signs": _signs_dict("Capricorn")}, +} + + +class _StubPySwissHandler(BaseHTTPRequestHandler): + """Impersonates PySwiss /api/windows/ so the FT drives the REAL epic proxy + + felt JS without the microservice.""" + + def do_GET(self): + parsed = urlparse(self.path) + if not parsed.path.startswith("/api/windows"): + self.send_response(404) + self.end_headers() + return + placements = parse_qs(parsed.query).get("placements", [""])[0] + n = len([p for p in placements.split(",") if p]) + payload = (_STUB_WINDOWS_AFTER_SATURN if n >= 2 + else _STUB_WINDOWS_AFTER_URANUS) + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass # keep the test output clean + + +class SeedMapClockNarrowingTest(FunctionalTest): + """Set the Game Clock — ephemeris narrowing (hard CSP, decision A in + project_voronoi_spec): each placement narrows the real ephemeris window the + ritual can still resolve to, and the NEXT planet's pickable signs shrink to + those reachable within it (PySwiss reverse lookup via /api/windows/). + + The narrowed date range prints in the aperture below the placement prompt + (#id_clock_windows) so the table can WATCH the window shrink; unreachable + sign wedges dim + go inert. PySwiss is stubbed in-process — the proxy, + the gating and the readout under test are all real.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + self.earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman", "card_count": 106, "is_default": True, + "is_polarized": True, "has_card_images": False}, + ) + self.card, _ = TarotCard.objects.get_or_create( + deck_variant=self.earthman, slug="clock-fixture-em", + defaults={"arcana": "MAJOR", "suit": None, "number": 0, "name": "Fixture"}, + ) + self._stub = ThreadingHTTPServer(("127.0.0.1", 0), _StubPySwissHandler) + threading.Thread(target=self._stub.serve_forever, daemon=True).start() + self._pyswiss_override = override_settings( + PYSWISS_URL=f"http://127.0.0.1:{self._stub.server_port}" + ) + self._pyswiss_override.enable() + + def tearDown(self): + self._pyswiss_override.disable() + self._stub.shutdown() + self._stub.server_close() + super().tearDown() + + def _open_seed_felt(self): + self.wait_for(lambda: self.assertNotIn( + "hex-phase-btn--out", + self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"), + )) + + def _click_and_assert_open(): + btn = self.browser.find_element(By.ID, "id_seed_map_btn") + self.browser.execute_script("arguments[0].click()", btn) + self.assertTrue(self.browser.execute_script( + "return document.documentElement.classList.contains('seed-open')" + )) + self.wait_for(_click_and_assert_open) + + def _wedge_class(self, sign): + return self.browser.find_element( + By.CSS_SELECTOR, f"#id_seed_wheel_svg [data-sign-name='{sign}']" + ).get_attribute("class") + + def test_window_readout_and_unreachable_sign_gating(self): + room = _seed_clock_room( + self.card, self.earthman, [("founder@test.io", 5, "NC")], + placements={"Uranus": "Aquarius"}, + ) + self.create_pre_authenticated_session("founder@test.io") + self.browser.get( + self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id})) + self._open_seed_felt() + + # The narrowed window prints below the prompt: first start → last end + # + the window count. + self.wait_for(lambda: self.assertIn( + "1995-04-01 → 1998-04-17", + self.browser.find_element(By.ID, "id_clock_windows").text, + )) + self.assertIn( + "2 windows", + self.browser.find_element(By.ID, "id_clock_windows").text, + ) + + # Saturn is reachable only in Pisces within those windows: the Pisces + # wedge is a live placement target, Aries is dimmed + inert. Each read + # re-finds inside wait_for — a repaint between find and read leaves a + # stale reference (drawRim rebuilds the svg). + self.wait_for(lambda: self.assertIn( + "nw-sign--blocked", self._wedge_class("Aries"))) + self.wait_for(lambda: self.assertIn( + "nw-sign--placeable", self._wedge_class("Pisces"))) + self.wait_for(lambda: self.assertNotIn( + "nw-sign--placeable", self._wedge_class("Aries"))) + + # A tap on the blocked wedge places nothing; the allowed wedge places + # Saturn (the final placements assertion proves Aries never landed). + _tap_sign(self.browser, "Aries") + _tap_sign(self.browser, "Pisces") + self.wait_for(lambda: self.assertEqual(self.browser.execute_script( + "return document.querySelectorAll(" + " '#id_seed_wheel_svg [data-planet=\"Saturn\"]').length" + ), 1)) + + room.refresh_from_db() + self.assertEqual( + room.clock_placements, {"Uranus": "Aquarius", "Saturn": "Pisces"} + ) + + # The readout re-narrows on the landed placement — the table watches + # the window shrink toward the moment the Moon will pin. + self.wait_for(lambda: self.assertIn( + "1996-01-12 → 1996-04-07", + self.browser.find_element(By.ID, "id_clock_windows").text, + )) + self.assertIn( + "1 window", + self.browser.find_element(By.ID, "id_clock_windows").text, + ) + + @tag("channels") class SeedMapClockBroadcastTest(ChannelsFunctionalTest): """Set the Game Clock — increment 2 (project_voronoi_spec): ONE shared map diff --git a/src/static/tests/SkyWheelSpec.js b/src/static/tests/SkyWheelSpec.js index 4dbcff4..754065f 100644 --- a/src/static/tests/SkyWheelSpec.js +++ b/src/static/tests/SkyWheelSpec.js @@ -1204,6 +1204,38 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { expect(aqua.classList.contains("nw-sign--placeable")).toBe(false); }); + it("R10: allowedSigns gates placement — blocked wedges dim + go inert", () => { + // Ephemeris narrowing (hard CSP): only signs the planet can actually + // reach within the narrowed windows stay placeable; the rest dim. + let picked = null; + SkyWheel.drawRim(rimSvg, null, { + placeable: "Saturn", + allowedSigns: ["Pisces"], + onPickSign: (s) => { picked = s; }, + }); + + const pisces = rimSvg.querySelector("[data-sign-name='Pisces']"); + const aries = rimSvg.querySelector("[data-sign-name='Aries']"); + expect(pisces.classList.contains("nw-sign--placeable")).toBe(true); + expect(aries.classList.contains("nw-sign--placeable")).toBe(false); + expect(aries.classList.contains("nw-sign--blocked")).toBe(true); + + tap(aries); + expect(picked).toBeNull(); + tap(pisces); + expect(picked).toBe("Pisces"); + }); + + it("R11: without allowedSigns every wedge stays placeable (narrowing unavailable)", () => { + let picked = null; + SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } }); + + expect(rimSvg.querySelectorAll(".nw-sign--placeable").length).toBe(12); + expect(rimSvg.querySelectorAll(".nw-sign--blocked").length).toBe(0); + tap(rimSvg.querySelector("[data-sign-name='Aries']")); + expect(picked).toBe("Aries"); + }); + it("R9: a placement tap never touches the interactive singleton", () => { skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); skySvg.setAttribute("id", "id_sky_svg"); diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index 3dda764..e311e65 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -305,6 +305,11 @@ html.seed-open .seed-page.seed-page--room { .nw-sign--placeable:hover > path[class*="nw-sign--"] { fill: rgba(var(--ninUser), 0.55); } + // Ephemeris narrowing: signs the active planet cannot reach within the + // narrowed windows — dimmed, inert (no pointer-events re-enable). + .nw-sign--blocked { + opacity: 0.35; + } } // Set the Game Clock — the placement prompt ("Place Uranus in a sign"), shown @@ -329,6 +334,25 @@ html.seed-open .seed-page.seed-page--room { } } +// The narrowed ephemeris window — "1996-01-12 → 1998-04-17 · 2 windows" — +// printed below the prompt for ALL viewers (shared ritual state): the table +// watches the range shrink toward the moment the Moon will pin. Empty (and +// invisible) until epic:clock_windows responds with narrowing data. +.seed-page--room .clock-windows { + position: absolute; + top: 3.6rem; + left: 50%; + transform: translateX(-50%); + z-index: 2; + pointer-events: none; + white-space: nowrap; + font-size: 0.8rem; + font-weight: 400; + letter-spacing: 0.04em; + color: rgba(var(--secUser), 0.85); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); +} + // 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/tests/SkyWheelSpec.js b/src/static_src/tests/SkyWheelSpec.js index 4dbcff4..754065f 100644 --- a/src/static_src/tests/SkyWheelSpec.js +++ b/src/static_src/tests/SkyWheelSpec.js @@ -1204,6 +1204,38 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { expect(aqua.classList.contains("nw-sign--placeable")).toBe(false); }); + it("R10: allowedSigns gates placement — blocked wedges dim + go inert", () => { + // Ephemeris narrowing (hard CSP): only signs the planet can actually + // reach within the narrowed windows stay placeable; the rest dim. + let picked = null; + SkyWheel.drawRim(rimSvg, null, { + placeable: "Saturn", + allowedSigns: ["Pisces"], + onPickSign: (s) => { picked = s; }, + }); + + const pisces = rimSvg.querySelector("[data-sign-name='Pisces']"); + const aries = rimSvg.querySelector("[data-sign-name='Aries']"); + expect(pisces.classList.contains("nw-sign--placeable")).toBe(true); + expect(aries.classList.contains("nw-sign--placeable")).toBe(false); + expect(aries.classList.contains("nw-sign--blocked")).toBe(true); + + tap(aries); + expect(picked).toBeNull(); + tap(pisces); + expect(picked).toBe("Pisces"); + }); + + it("R11: without allowedSigns every wedge stays placeable (narrowing unavailable)", () => { + let picked = null; + SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } }); + + expect(rimSvg.querySelectorAll(".nw-sign--placeable").length).toBe(12); + expect(rimSvg.querySelectorAll(".nw-sign--blocked").length).toBe(0); + tap(rimSvg.querySelector("[data-sign-name='Aries']")); + expect(picked).toBe("Aries"); + }); + it("R9: a placement tap never touches the interactive singleton", () => { skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); skySvg.setAttribute("id", "id_sky_svg"); diff --git a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html index d6f88dd..165b95a 100644 --- a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html +++ b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html @@ -11,6 +11,7 @@ {# hub. See project_voronoi_spec. #}
@@ -23,6 +24,10 @@ {# Placement prompt — only for the gamer whose position circle's turn it is. #}
Place {{ clock_placeable }}
in a sign
{% endif %} + {# The narrowed ephemeris window — every placement shrinks the real date #} + {# range the ritual can still resolve to; printed for ALL viewers (shared #} + {# state) below the prompt. Filled lazily from epic:clock_windows. #} +
@@ -46,6 +51,41 @@ // The viewer's own position circle — compared against a broadcast's // next_slot to receive the live turn handoff. var _mySlot = parseInt(overlay.dataset.clockSlot, 10) || null; + // Ephemeris narrowing (hard CSP): the signs the active planet can reach + // within the narrowed windows — null until fetched / when PySwiss is + // unavailable (= no narrowing, all 12 placeable). + var _allowedSigns = null; + + // Fetch the narrowed windows + sign gating from epic:clock_windows (lazy — + // the room view stays HTTP-free) and print the date range below the prompt: + // "1996-01-12 → 1998-04-17 · 2 windows". A fetch seq guards against an + // out-of-order response repainting stale narrowing over fresh. + var _windowsSeq = 0; + function _renderWindows(j) { + var el = document.getElementById('id_clock_windows'); + if (!el) return; + if (!j || !j.available || !j.windows || !j.windows.length) { + el.textContent = ''; + return; + } + var first = j.windows[0].start.slice(0, 10); + var last = j.windows[j.windows.length - 1].end.slice(0, 10); + var n = j.windows.length; + el.textContent = first + ' → ' + last + ' · ' + n + + (n === 1 ? ' window' : ' windows'); + } + function _fetchWindows() { + if (!overlay.dataset.clockWindowsUrl) return; + var seq = ++_windowsSeq; + window.fetch(overlay.dataset.clockWindowsUrl, { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { + if (seq !== _windowsSeq || !j) return; + _allowedSigns = (j.available && j.allowed_signs) || null; + _renderWindows(j); + _paint(); + }).catch(function () {}); + } var SIGN_ORDER = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']; @@ -84,6 +124,7 @@ var prompt = document.getElementById('id_clock_prompt'); if (prompt) prompt.parentNode.removeChild(prompt); _paint(); + _fetchWindows(); // the landed placement re-narrows the window }).catch(function () {}); } @@ -110,8 +151,11 @@ _ensurePrompt(d.next_planet); } // Repaint even while closed — visibility:hidden retains the felt's layout - // box, and openSeed repaints again anyway. + // box, and openSeed repaints again anyway. The glyph lands instantly; the + // re-narrowed window + sign gating follow on the fetch. + _allowedSigns = null; _paint(); + _fetchWindows(); }); // Phase-btn shed: drop #id_text_btn .active while the felt is up (sea-style); @@ -142,7 +186,9 @@ // never window.SkyWheel (always undefined). var geo = null; if (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim) { - var opts = _placeable ? { placeable: _placeable, onPickSign: _onPickSign } : undefined; + var opts = _placeable + ? { placeable: _placeable, onPickSign: _onPickSign, allowedSigns: _allowedSigns } + : undefined; geo = SkyWheel.drawRim(wheelSvg, _rimData(), opts); } if (geo) { @@ -214,5 +260,9 @@ // _burger.html elements (included after); there is no burger seed sub-btn. var seedBtn = document.getElementById('id_seed_map_btn'); if (seedBtn) seedBtn.addEventListener('click', openSeed); + + // Prime the narrowing at parse time so the readout + sign gating are ready + // by the time the felt opens (server-cached per placements — cheap). + _fetchWindows(); }());