Set the Game Clock — ephemeris narrowing (hard CSP): placements shrink the REAL date window; narrowed range prints below the prompt; unreachable signs dim + reject — TDD

- 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 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-10 12:30:16 -04:00
parent b2ddd98956
commit 32db203543
15 changed files with 888 additions and 7 deletions

View File

@@ -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}

View File

@@ -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)

View File

@@ -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')

View File

@@ -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'),
]

View File

@@ -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()

View File

@@ -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')

View File

@@ -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

View File

@@ -34,6 +34,7 @@ urlpatterns = [
path('room/<uuid:room_id>/sky/delete', views.sky_delete, name='sky_delete'),
path('room/<uuid:room_id>/sky/table', views.table_sky, name='table_sky'),
path('room/<uuid:room_id>/clock/place', views.place_clock_planet, name='place_clock_planet'),
path('room/<uuid:room_id>/clock/windows', views.clock_windows, name='clock_windows'),
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'),
path('room/<uuid:room_id>/sea/save', views.sea_save, name='sea_save'),

View File

@@ -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"])

View File

@@ -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();

View File

@@ -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

View File

@@ -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");

View File

@@ -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;

View File

@@ -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");

View File

@@ -11,6 +11,7 @@
{# hub. See project_voronoi_spec. #}
<div class="seed-page seed-page--room" id="id_seed_map_overlay"
data-clock-place-url="{% url 'epic:place_clock_planet' room.id %}"
data-clock-windows-url="{% url 'epic:clock_windows' room.id %}"
data-clock-placeable="{{ clock_placeable|default:'' }}"
data-clock-slot="{{ clock_slot|default:'' }}">
<div class="seed-map-body">
@@ -23,6 +24,10 @@
{# Placement prompt — only for the gamer whose position circle's turn it is. #}
<div id="id_clock_prompt" class="clock-prompt">Place {{ clock_placeable }}<br><small>in a sign</small></div>
{% 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. #}
<div id="id_clock_windows" class="clock-windows"></div>
</div>
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/voronoi-map.js' %}"></script>
@@ -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();
}());
</script>