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:
@@ -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}
|
||||
|
||||
81
pyswiss/apps/charts/tests/integrated/test_windows_api.py
Normal file
81
pyswiss/apps/charts/tests/integrated/test_windows_api.py
Normal 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)
|
||||
113
pyswiss/apps/charts/tests/unit/test_windows.py
Normal file
113
pyswiss/apps/charts/tests/unit/test_windows.py
Normal 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')
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user