Set the Game Clock — increment 1: the position-circle-6 gamer places Uranus in a sign on the shared game wheel — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

The SEED MAP rim's planets are no longer auto-computed — they are PLACED by the
gamers in turn (circle 6→1, Uranus→Saturn→Jupiter→Mars→Sun→Moon; Mercury/Venus
Sun-derived; Neptune/Pluto excluded). This is the first turn: the wheel starts
SIGNS-ONLY (planets eliminated) and the gamer at POSITION CIRCLE 6 places Uranus
by clicking a sign wedge. Roadmap step 21. See project_voronoi_spec.

KEYED ON THE POSITION CIRCLE (slot_number), NOT the role — select_role assigns
roles freely, so position 6 can hold any role; in a generic room BC merely
defaults there. Tests INVERT the slot→role defaults (position 6 = PC, position 1
= BC) so a role-keyed bug would fail them.

- Room.clock_placements JSONField (migration 0020): {planet: sign}, the ritual
  state. The 9ed8771 auto-sky infra (table_sky / sky_chart / convened_at) stays
  DORMANT — future home of the resolved sky; the rim no longer reads it.
- epic.place_clock_planet POST {planet, sign}: the acting seat must be the circle
  whose turn it is (its circle's planet, every earlier planet placed, this one
  not yet) + a real zodiac sign → persists. _clock_placeable_for shared by the
  endpoint + the seed-felt ctx so gate & affordance can't drift. 403 wrong
  circle/turn/seat, 400 bad sign.
- SkyWheel.drawRim(svg, data, opts): opts.placeable turns the sign wedges into
  placement targets (.nw-sign--placeable + click → opts.onPickSign(sign)); still
  singleton-pure (no module writes — Jasmine R9).
- _seed_map_overlay.html: rim renders the placements (sign-midpoint glyph);
  empty → signs-only; the gamer whose turn it is gets the #id_clock_prompt + the
  clickable wedges → POST → adopt the server's placements + repaint (reload-safe).
- _sky.scss: .nw-sign--placeable re-enables pointer-events on the click-through
  rim + a hover brighten; the .clock-prompt label.
- ctx: clock_placements_json + clock_placeable in _role_select_context (drops the
  now-unused room_sky_json). Repointed the 9ed8771 rim FT/ITs (auto-sky →
  placements, empty = signs-only).
- Coverage: Jasmine drawRim R7–R9 (placement clickable / inert without opts /
  singleton-safe); epic PlaceClockPlanetTest (position-keyed, role-inverted) +
  repointed rim ITs; FT position-6 places Uranus → glyph + persist. 1021
  epic+gameboard ITs green; live-verified in Firefox.

DEFERRED: turn progression 5→1 + WS live-broadcast (increment 2); the CSP
ephemeris narrowing + resolving placements → a datetime (later).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-10 01:06:14 -04:00
parent 9ed877168e
commit 14afb108c0
11 changed files with 557 additions and 96 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-06-10 04:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0019_room_convened_at_room_sky_chart'),
]
operations = [
migrations.AddField(
model_name='room',
name='clock_placements',
field=models.JSONField(blank=True, default=dict),
),
]

View File

@@ -64,6 +64,11 @@ class Room(models.Model):
# LAZILY by the table_sky view — legacy rooms key off created_at.
convened_at = models.DateTimeField(null=True, blank=True)
sky_chart = models.JSONField(null=True, blank=True)
# Set the Game Clock ritual ([[project-voronoi-spec]]): {planet: sign} placed
# by the gamers in turn (circle 6→1, Uranus→…→Moon) — the shared game wheel's
# planet source. Resolving these to a datetime → the official sky is a later
# increment; for now the rim renders the placements directly (sign granularity).
clock_placements = models.JSONField(default=dict, blank=True)
def get_thread_post(self):
"""Get-or-create this room's single game-table thread Post (the POST

View File

@@ -3016,6 +3016,97 @@ class TableSkyViewTest(TestCase):
self.assertEqual(response.status_code, 403)
class PlaceClockPlanetTest(TestCase):
"""Set the Game Clock — increment 1 ([[project-voronoi-spec]]): the six seats
set the game's start time by placing planets in signs on the shared wheel, in
turn order circle 6 → 1 (Uranus→Saturn→Jupiter→Mars→Sun→Moon). This covers
the placement endpoint's gating (right circle for the planet, that planet's
turn, valid sign, not re-placeable) + persistence to Room.clock_placements.
The ritual keys on the POSITION CIRCLE (slot_number), NOT the role — roles are
freely chosen in select_role, so position 6 can hold any role. The fixture
INVERTS the slot→role defaults (position 6 = PC, position 1 = BC) to prove it:
the position-6 gamer places Uranus first regardless of being 'PC'."""
def setUp(self):
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
self.p6 = User.objects.create(email="p6@clock.io")
self.room = Room.objects.create(
name="Clock", owner=self.p6, table_status=Room.SKY_SELECT
)
slot = self.room.gate_slots.get(slot_number=6)
slot.gamer = self.p6
slot.status = GateSlot.FILLED
slot.save()
self.room.gate_status = Room.OPEN
self.room.save()
# Position circle 6, role deliberately PC (not the slot-6 default BC) —
# the ritual must key on the position, not the role.
self.seat = TableSeat.objects.create(
room=self.room, gamer=self.p6, slot_number=6, role="PC",
deck_variant=self.earthman,
)
self.url = reverse("epic:place_clock_planet", kwargs={"room_id": self.room.id})
self.client.force_login(self.p6)
def _post(self, planet="Uranus", sign="Aquarius"):
return self.client.post(self.url, {"planet": planet, "sign": sign})
def test_position_6_places_uranus(self):
response = self._post()
self.assertEqual(response.status_code, 200)
self.room.refresh_from_db()
self.assertEqual(self.room.clock_placements, {"Uranus": "Aquarius"})
def test_get_not_allowed(self):
self.assertEqual(self.client.get(self.url).status_code, 405)
def test_invalid_sign_rejected(self):
response = self._post(sign="Notasign")
self.assertEqual(response.status_code, 400)
self.room.refresh_from_db()
self.assertEqual(self.room.clock_placements, {})
def test_wrong_planet_for_circle_rejected(self):
"""Position circle 6 places Uranus, not Saturn (circle 5's planet)."""
response = self._post(planet="Saturn", sign="Aquarius")
self.assertEqual(response.status_code, 403)
self.room.refresh_from_db()
self.assertEqual(self.room.clock_placements, {})
def test_keys_on_position_not_role(self):
"""The BC role at POSITION 1 still can't open the ritual — Uranus is
position 6's, whoever sits there. (Inverted defaults: a 'BC' gamer who is
NOT at circle 6 is powerless; the 'PC' gamer AT circle 6 is the one who
places Uranus, proven by test_position_6_places_uranus.)"""
bc_at_1 = User.objects.create(email="bc-at-1@clock.io")
slot = self.room.gate_slots.get(slot_number=1)
slot.gamer = bc_at_1
slot.status = GateSlot.FILLED
slot.save()
TableSeat.objects.create(
room=self.room, gamer=bc_at_1, slot_number=1, role="BC",
deck_variant=self.earthman,
)
self.client.force_login(bc_at_1)
self.assertEqual(self._post().status_code, 403)
def test_uranus_not_replaceable_once_placed(self):
self._post() # Uranus → Aquarius
response = self._post(sign="Aries") # try to move it
self.assertEqual(response.status_code, 403)
self.room.refresh_from_db()
self.assertEqual(self.room.clock_placements, {"Uranus": "Aquarius"})
def test_unseated_gamer_403(self):
outsider = User.objects.create(email="out@clock.io")
self.client.force_login(outsider)
self.assertEqual(self._post().status_code, 403)
# ── tarot_deal ────────────────────────────────────────────────────────────────
class TarotDealViewTest(TestCase):
@@ -4741,33 +4832,33 @@ class PickSeaUnifiedFeltTest(TestCase):
content = self.client.get(self.url).content.decode()
self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3)
def test_seed_map_overlay_renders_shared_room_sky_rim(self):
"""Step 2's shared frame: the felt carries the wheel-rim svg + the
ROOM's own sky (one canonical frame for all six gamers — NOT the
viewing seat's natal chart), drawn via SkyWheel.drawRim. sky-wheel.js
must NOT be re-included — the sky overlay already loads it, and a
second top-level `const SkyWheel` declaration throws."""
def test_seed_map_overlay_renders_shared_wheel_rim(self):
"""Step 2's shared frame: the felt carries the wheel-rim svg, drawn via
SkyWheel.drawRim from the room's PLACED planets (Room.clock_placements,
the Game Clock ritual — one canonical frame for all six gamers, NOT the
viewing seat's natal chart). sky-wheel.js must NOT be re-included — the
sky overlay already loads it, and a second top-level `const SkyWheel`
declaration throws."""
self._complete_hand()
self.room.sky_chart = {
"planets": {"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}},
"aspects": [],
}
self.room.save(update_fields=["sky_chart"])
self.room.clock_placements = {"Uranus": "Aquarius"}
self.room.save(update_fields=["clock_placements"])
content = self.client.get(self.url).content.decode()
self.assertIn('id="id_seed_wheel_svg"', content)
self.assertIn("SkyWheel.drawRim", content)
self.assertIn("338.4", content) # the room sky, embedded
self.assertIn("data-table-sky-url", content) # lazy compute endpoint
self.assertIn("Aquarius", content) # the placements, embedded
self.assertIn("data-clock-place-url", content) # the placement endpoint
self.assertEqual(content.count("apps/gameboard/sky-wheel.js"), 1)
def test_seed_map_overlay_rim_survives_missing_room_sky(self):
"""No stored room sky yet → the rim svg + lazy fetch URL still render:
the felt fetches table_sky on open (which computes + caches), and the
canonical signs-only frame draws while the planets are absent."""
def test_seed_map_overlay_no_placement_affordance_for_non_circle_6(self):
"""The placement prompt shows only for the gamer whose turn it is. The
founder here is PC (circle 1 = the Moon, last), so with nothing placed
it is NOT their turn → no #id_clock_prompt — but the rim + the placement
endpoint URL still render (any seated gamer sees the shared wheel)."""
self._complete_hand()
content = self.client.get(self.url).content.decode()
self.assertIn('id="id_seed_wheel_svg"', content)
self.assertIn("data-table-sky-url", content)
self.assertIn("data-clock-place-url", content)
self.assertNotIn('id="id_clock_prompt"', content)
def test_sea_felt_leaves_sky_btn_lit_and_swaps_cleanly(self):
"""Symmetric with Sky Select (which leaves the sea btn lit while the sky

View File

@@ -33,6 +33,7 @@ urlpatterns = [
path('room/<uuid:room_id>/sky/save', views.sky_save, name='sky_save'),
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>/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

@@ -696,11 +696,14 @@ def _role_select_context(room, user, seat_param=None):
if (sky_confirmed and confirmed_char.chart_data)
else "null"
)
# The table's OWN sky — the shared SEED MAP rim frame (one canonical
# frame for all six gamers, NOT this seat's natal chart). None until
# the lazy table_sky endpoint computes + caches it; the felt then
# fetches it on open.
ctx["room_sky_json"] = json.dumps(room.sky_chart) if room.sky_chart else None
# Set the Game Clock — the shared SEED MAP wheel's planets are PLACED by
# the gamers (Room.clock_placements), one canonical frame for all six.
# The rim renders the placements; the gamer whose POSITION CIRCLE's turn
# it is gets the placement affordance (`clock_placeable` = their circle's
# planet, keyed on slot_number not role). [[project-voronoi-spec]]
_placements = room.clock_placements or {}
ctx["clock_placements_json"] = json.dumps(_placements)
ctx["clock_placeable"] = _clock_placeable_for(_sky_seat, _placements)
if sky_confirmed:
# Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator
@@ -1873,6 +1876,63 @@ def table_sky(request, room_id):
return JsonResponse(room.sky_chart)
# Set the Game Clock ritual ([[project-voronoi-spec]]): the six seats place a
# planet each in turn, circle 6 → 1, by decreasing orbital period so each turn
# narrows the start-time window. Mercury + Venus are Sun-bound (≤28°/≤48°
# elongation) → DERIVED, not placed; Neptune/Pluto excluded (too slow to pin a
# time). The CSP sign-narrowing + resolving placements → a datetime are later.
CLOCK_PLANET_BY_SLOT = {6: "Uranus", 5: "Saturn", 4: "Jupiter", 3: "Mars", 2: "Sun", 1: "Moon"}
CLOCK_ORDER = ["Uranus", "Saturn", "Jupiter", "Mars", "Sun", "Moon"] # placement order
_ZODIAC_SIGNS = {
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
}
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
None. Shared by the placement endpoint + the seed-felt context so the gate
and the affordance can't drift apart."""
if seat is None:
return None
planet = CLOCK_PLANET_BY_SLOT.get(seat.slot_number)
if planet is None or planet in placements:
return None
idx = CLOCK_ORDER.index(planet)
if any(p not in placements for p in CLOCK_ORDER[:idx]):
return None
return planet
@login_required
def place_clock_planet(request, room_id):
"""Set the Game Clock — place a planet in a sign on the shared game wheel
([[project-voronoi-spec]]). POST {planet, sign}. The acting seat must be the
circle whose turn it is (circle 6 places Uranus first, then 5→1); the posted
planet must be exactly the one that seat may place now; the sign must be a
real zodiac sign. Persists to Room.clock_placements. Returns {ok, placements}
200 · 400 bad sign · 403 not your seat / not your turn / not seated.
"""
if request.method != "POST":
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
seat = _acting_seat(room, request.user, request.GET.get("seat"))
if seat is None:
return HttpResponse(status=403)
planet = (request.POST.get("planet") or "").strip()
sign = (request.POST.get("sign") or "").strip()
if sign not in _ZODIAC_SIGNS:
return JsonResponse({"error": "invalid_sign"}, status=400)
placements = dict(room.clock_placements or {})
if planet != _clock_placeable_for(seat, placements):
return HttpResponse(status=403)
placements[planet] = sign
room.clock_placements = placements
room.save(update_fields=["clock_placements"])
return JsonResponse({"ok": True, "placements": placements})
@login_required
def sky_save(request, room_id):
"""Create or update the draft Character for the requesting gamer's seat.

View File

@@ -1460,12 +1460,19 @@ const SkyWheel = (() => {
* survive a rim draw untouched. Reads the module's _signPaths cache
* (preload()ed by the sky overlay, which renders on every SKY_SELECT page).
*
* 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.
*
* Returns {size, cx, cy, r, hubR} — hubR (just inside the planet band) is
* the radius the felt sizes the tessellation svg into (2 × hubR square +
* circle clip), or null without an svg/d3.
*/
function drawRim(svgEl, data) {
function drawRim(svgEl, data, opts) {
if (!svgEl || !window.d3) return null;
opts = opts || {};
const sel = d3.select(svgEl);
sel.selectAll('*').remove();
@@ -1495,6 +1502,14 @@ const SkyWheel = (() => {
const slice = sigGroup.append('g')
.attr('class', 'nw-sign-group')
.attr('data-sign-name', sign.name);
// Placement mode (Set the Game Clock): the wedge is a placement target.
if (opts.placeable) {
slice.classed('nw-sign--placeable', true).style('cursor', 'pointer')
.on('click', function (event) {
event.stopPropagation();
if (opts.onPickSign) opts.onPickSign(sign.name);
});
}
slice.append('path')
.attr('transform', `translate(${cx},${cy})`)
.attr('d', arc({

View File

@@ -7,23 +7,25 @@ d3-delaunay DUAL GRAPH — a Voronoi cell layer (territory) + a Delaunay edge
layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2),
so we assert only that the dual graph renders both layers.
Step 2's first piece — the SHARED WHEEL RIM: the stripped sky wheel (signs ring
at canonical orientation + planet glyphs; NO element ring, centre disc, houses
or axes — a virtual table has a convened TIME but no birth LOCATION) rings the
tessellation, drawn from the ROOM'S OWN sky (Room.sky_chart, identical for all
six gamers). The map svg shrinks into the wheel's freed hub. The fixture
pre-stores Room.sky_chart so the lazy PySwiss compute path never fires HTTP.
Step 2's frame — the SHARED WHEEL RIM: the stripped sky wheel (signs ring at
canonical orientation + planet glyphs; NO element ring, centre disc, houses or
axes — a virtual table has a convened TIME but no birth LOCATION) rings the
tessellation. The rim's planets are no longer auto-computed: they are PLACED by
the gamers in the "Set the Game Clock" ritual (Room.clock_placements), so the
wheel starts SIGNS-ONLY (planets eliminated) and gains a glyph per placement.
The map svg shrinks into the wheel's freed hub.
Plain FunctionalTest (NOT @tag("channels")): the felt-open is pure client-side.
We seed a CONFIRMED Character with a complete hand directly in the DB, so there
is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs.
"""
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from selenium.webdriver.common.by import By
from apps.epic.models import Character, DeckVariant, TarotCard
from apps.epic.models import Character, DeckVariant, GateSlot, Room, TableSeat, TarotCard
from apps.lyric.models import User
from .base import FunctionalTest
@@ -32,17 +34,6 @@ from .test_game_room_select_sea import _make_sky_confirmed_room
_HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"]
# The room's own sky — planets only (location-independent), mirroring the
# Jasmine ROOM_SKY fixture. Mercury retrograde exercises the ℞ badge.
_ROOM_SKY = {
"planets": {
"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False},
"Moon": {"sign": "Capricorn", "degree": 295.1, "retrograde": False},
"Mercury": {"sign": "Aquarius", "degree": 312.8, "retrograde": True},
},
"aspects": [],
}
class SeedMapFeltTest(FunctionalTest):
def setUp(self):
@@ -71,10 +62,6 @@ class SeedMapFeltTest(FunctionalTest):
celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete
=> SEED MAP is the live phase btn + the felt renders."""
room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman)
# Pre-store the table's own sky so the rim renders without the lazy
# PySwiss compute (no live HTTP from FTs).
room.sky_chart = _ROOM_SKY
room.save(update_fields=["sky_chart"])
hand = [
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
for p in _HAND_POSITIONS[:hand_len]
@@ -131,15 +118,17 @@ class SeedMapFeltTest(FunctionalTest):
), 1,
)
def test_seed_map_felt_rings_tessellation_with_room_sky_rim(self):
"""Step 2's shared frame: the stripped sky wheel — canonical signs ring +
the ROOM's own planets, NOTHING location-bound (no houses/axes) and none
of the stripped chrome (element ring / centre disc / aspect web) — rings
the tessellation, which shrinks into the wheel's freed hub."""
room = self._seed_room(hand_len=6)
def test_seed_map_rim_is_signs_only_until_placement(self):
"""Step 2's shared frame: the stripped sky wheel rings the tessellation —
canonical signs ring only, NO planets until the Game Clock ritual places
them (the wheel starts planet-eliminated), none of the stripped chrome
(element ring / centre disc / aspect web) and nothing location-bound
(houses, ASC/MC axes). The map shrinks into the wheel's freed hub."""
room = self._seed_room(hand_len=6) # no clock placements yet
self._open_seed_felt(room)
# The rim paints: 12 canonical sign slices + the room sky's 3 planets.
# The rim paints the 12 canonical sign slices, but ZERO planets — they
# are placed by gamers in the ritual, not auto-computed.
self.wait_for(lambda: self.assertEqual(
self.browser.execute_script(
"return document.querySelectorAll('#id_seed_wheel_svg .nw-sign-group').length"
@@ -148,7 +137,7 @@ class SeedMapFeltTest(FunctionalTest):
self.assertEqual(
self.browser.execute_script(
"return document.querySelectorAll('#id_seed_wheel_svg .nw-planet-group').length"
), 3,
), 0,
)
# The strip: no element ring / centre disc / aspect web, and no
@@ -195,3 +184,129 @@ class SeedMapFeltTest(FunctionalTest):
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
)
self.assertEqual(self.browser.find_elements(By.ID, "id_seed_map_overlay"), [])
class SeedMapClockTest(FunctionalTest):
"""Set the Game Clock — increment 1 (project_voronoi_spec): the six seats
collaboratively set the game's start time by placing planets in signs on the
shared game wheel, in turn order circle 6 → 1. This covers the FIRST turn:
the position-circle-6 gamer places URANUS. The wheel starts signs-only;
clicking a sign wedge places Uranus there → Room.clock_placements → a glyph
on every gamer's wheel (reload-safe). Turn progression 5→1, WS live-broadcast
and the ephemeris narrowing are later increments.
The ritual keys on the POSITION CIRCLE (slot_number), not the role — so the
fixture seats position 6 with role PC (not the slot-6 default BC) to prove it.
Plain FunctionalTest: the placement is a POST + repaint, no WS flow yet.
"""
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.gamer, _ = User.objects.get_or_create(email="founder@test.io")
self.gamer.unlocked_decks.add(self.earthman)
self.gamer.equipped_deck = self.earthman
self.gamer.save(update_fields=["equipped_deck"])
def _seed_position6_room(self):
"""SKY_SELECT room, founder seated at POSITION CIRCLE 6 with a complete
sea hand (so the SEED MAP felt — the clock ritual's host surface — is
reachable). Role is PC, NOT the slot-6 default BC, to prove the ritual
keys on the position. No clock placements yet."""
room = Room.objects.create(
name="Clock Room", table_status=Room.SKY_SELECT, owner=self.gamer
)
slot = room.gate_slots.get(slot_number=6)
slot.gamer = self.gamer
slot.status = GateSlot.FILLED
slot.save()
room.gate_status = Room.OPEN
room.save()
sig = (TarotCard.objects.filter(deck_variant=self.earthman, arcana="MAJOR")
.first() or self.card)
seat = TableSeat.objects.create(
room=room, gamer=self.gamer, role="PC", slot_number=6,
deck_variant=self.earthman, significator=sig,
)
hand = [
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
for p in _HAND_POSITIONS
]
Character.objects.create(
seat=seat, significator=sig, chart_data={"planets": {}},
confirmed_at=timezone.now(),
celtic_cross={"spread": "waite-smith", "hand": hand},
)
return room
def _room_url(self, room):
return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id})
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 _uranus_count(self):
return self.browser.execute_script(
"return document.querySelectorAll("
" '#id_seed_wheel_svg [data-planet=\"Uranus\"]').length"
)
def test_position_6_gamer_places_uranus_in_a_sign(self):
room = self._seed_position6_room()
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self._room_url(room))
self._open_seed_felt()
# The position-6 gamer is prompted to place Uranus, and the wheel starts
# with NO planet placed.
self.wait_for(lambda: self.assertIn(
"Uranus",
self.browser.find_element(By.ID, "id_clock_prompt").text,
))
self.assertEqual(self._uranus_count(), 0)
# Clicking the Aquarius sign wedge places Uranus there. SVG <g> elements
# don't expose .click() in Firefox — dispatch the event (TDD skill).
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
self.browser.find_element(
By.CSS_SELECTOR, "#id_seed_wheel_svg [data-sign-name='Aquarius']"),
)
self.wait_for(lambda: self.assertEqual(self._uranus_count(), 1))
self.assertEqual(
self.browser.find_element(
By.CSS_SELECTOR, "#id_seed_wheel_svg [data-planet='Uranus']"
).get_attribute("data-sign"),
"Aquarius",
)
# Persisted to the room: a reload re-renders Uranus in Aquarius, and the
# prompt is gone (Uranus is placed — circle 6's turn is done).
room.refresh_from_db()
self.assertEqual(room.clock_placements, {"Uranus": "Aquarius"})
self.browser.get(self._room_url(room))
self._open_seed_felt()
self.wait_for(lambda: self.assertEqual(self._uranus_count(), 1))
self.assertEqual(self.browser.find_elements(By.ID, "id_clock_prompt"), [])

View File

@@ -1145,4 +1145,46 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => {
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
});
// ── Placement mode (Set the Game Clock ritual) ──────────────────────────
// drawRim(svg, data, {placeable, onPickSign}) turns the sign wedges into
// placement targets: the gamer whose turn it is clicks a sign to place the
// active planet there. Still singleton-pure — no SkyWheel module writes.
it("R7: placement mode makes the sign wedges clickable and reports the picked sign", () => {
let picked = null;
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } });
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
expect(aqua.classList.contains("nw-sign--placeable")).toBe(true);
aqua.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(picked).toBe("Aquarius");
});
it("R8: without placement opts the sign wedges stay inert (no placeable class)", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
expect(aqua.classList.contains("nw-sign--placeable")).toBe(false);
});
it("R9: a placement click never touches the interactive singleton", () => {
skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
skySvg.setAttribute("id", "id_sky_svg");
skySvg.style.width = "400px";
skySvg.style.height = "400px";
document.body.appendChild(skySvg);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_sky_tooltip";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
SkyWheel.draw(skySvg, CONJUNCTION_CHART);
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => {} });
rimSvg.querySelector("[data-sign-name='Aries']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// The interactive wheel's tooltip controls survive; no sign locked active.
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull();
expect(skySvg.querySelectorAll(".nw-sign--active").length).toBe(0);
});
});

View File

@@ -281,10 +281,11 @@ html.seed-open .seed-page.seed-page--room {
}
}
// The shared wheel rim — the stripped sky wheel (the ROOM's planets-only sky,
// canonical signs frame) drawn by SkyWheel.drawRim. Sits ABOVE the map svg in
// source order; pointer-events none so territory interaction falls through.
// The .nw-* ring/planet styling rules below apply to it unchanged.
// The shared wheel rim — the stripped sky wheel (canonical signs frame + the
// gamer-PLACED planets, Set the Game Clock) drawn by SkyWheel.drawRim. Sits
// ABOVE the map svg in source order; pointer-events none so territory
// interaction falls through. The .nw-* ring/planet styling rules below apply
// to it unchanged.
.seed-page--room svg.seed-wheel {
display: block;
position: absolute;
@@ -292,6 +293,40 @@ html.seed-open .seed-page.seed-page--room {
width: 100%;
height: 100%;
pointer-events: none;
// Placement mode: the sign wedges are clickable targets for the gamer whose
// position circle's turn it is. Re-enable pointer-events ONLY on those
// wedges (the rest of the rim stays click-through); brighten on hover (the
// cursor:pointer is set inline by drawRim). [[project-voronoi-spec]]
.nw-sign--placeable {
pointer-events: auto;
cursor: pointer;
}
.nw-sign--placeable:hover > path[class*="nw-sign--"] {
fill: rgba(var(--ninUser), 0.55);
}
}
// Set the Game Clock — the placement prompt ("Place Uranus in a sign"), shown
// only to the gamer whose position circle's turn it is. Pinned to the top of
// the felt, above the wheel + map (both pointer-events-light), click-through.
.seed-page--room .clock-prompt {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 2;
pointer-events: none;
text-align: center;
color: rgba(var(--secUser), 1);
font-weight: 700;
letter-spacing: 0.05em;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
small {
font-weight: 400;
opacity: 0.8;
}
}
// Hide the z-130 position strip while the felt is up (mirrors sky/sea).

View File

@@ -1145,4 +1145,46 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => {
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
});
// ── Placement mode (Set the Game Clock ritual) ──────────────────────────
// drawRim(svg, data, {placeable, onPickSign}) turns the sign wedges into
// placement targets: the gamer whose turn it is clicks a sign to place the
// active planet there. Still singleton-pure — no SkyWheel module writes.
it("R7: placement mode makes the sign wedges clickable and reports the picked sign", () => {
let picked = null;
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } });
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
expect(aqua.classList.contains("nw-sign--placeable")).toBe(true);
aqua.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(picked).toBe("Aquarius");
});
it("R8: without placement opts the sign wedges stay inert (no placeable class)", () => {
SkyWheel.drawRim(rimSvg, ROOM_SKY);
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
expect(aqua.classList.contains("nw-sign--placeable")).toBe(false);
});
it("R9: a placement click never touches the interactive singleton", () => {
skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
skySvg.setAttribute("id", "id_sky_svg");
skySvg.style.width = "400px";
skySvg.style.height = "400px";
document.body.appendChild(skySvg);
tooltipEl = document.createElement("div");
tooltipEl.id = "id_sky_tooltip";
tooltipEl.style.display = "none";
document.body.appendChild(tooltipEl);
SkyWheel.draw(skySvg, CONJUNCTION_CHART);
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => {} });
rimSvg.querySelector("[data-sign-name='Aries']")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
// The interactive wheel's tooltip controls survive; no sign locked active.
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull();
expect(skySvg.querySelectorAll(".nw-sign--active").length).toBe(0);
});
});

View File

@@ -2,20 +2,26 @@
{# SEED MAP felt — d3-delaunay dual graph (Voronoi cells = territory + Delaunay #}
{# edges = adjacency) on a my_sea-style --duoUser felt filling .room-hex-pane. #}
{# Inline sibling of CAST SKY (_sky_overlay) / DRAW SEA (_sea_overlay). Opens on #}
{# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; Step 2's first #}
{# piece is live: the SHARED wheel rim — the ROOM's own sky (planets only; no #}
{# location => no houses/axes), one canonical frame for all six gamers — rings #}
{# the tessellation, which _paint() sizes into the wheel's freed hub. #}
{# Card-driven seeding (the 6 Celtic-Cross cards) is the next piece. #}
{# See project_voronoi_spec. #}
{# #id_seed_map_btn (html.seed-open). The dual-graph map is ringed by the SHARED #}
{# game wheel — the stripped sky-wheel rim whose planets are PLACED by the #}
{# gamers in the "Set the Game Clock" ritual (Room.clock_placements), one #}
{# canonical frame for all six. The wheel starts SIGNS-ONLY; the gamer whose #}
{# POSITION CIRCLE's turn it is (circle 6 places Uranus first) clicks a sign #}
{# wedge to place their planet. _paint() sizes the map into the wheel's freed #}
{# hub. See project_voronoi_spec. #}
<div class="seed-page seed-page--room" id="id_seed_map_overlay"
data-table-sky-url="{% url 'epic:table_sky' room.id %}">
data-clock-place-url="{% url 'epic:place_clock_planet' room.id %}"
data-clock-placeable="{{ clock_placeable|default:'' }}">
<div class="seed-map-body">
<div class="seed-map-col">
<svg id="id_seed_map_svg" class="voronoi-map"></svg>
<svg id="id_seed_wheel_svg" class="seed-wheel"></svg>
</div>
</div>
{% if clock_placeable %}
{# 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 %}
</div>
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/voronoi-map.js' %}"></script>
@@ -31,11 +37,51 @@
var wheelSvg = document.getElementById('id_seed_wheel_svg');
if (!overlay || !svgEl) return;
// The table's OWN sky (the shared rim frame) — embedded when already
// computed, else lazily fetched on first open (the endpoint computes via
// PySwiss at the convened moment + caches on the Room).
var _tableSky = {{ room_sky_json|default:"null"|safe }};
var _skyFetched = false;
// The shared wheel's planets — PLACED by the gamers, {planet: sign}. The rim
// renders these (sign granularity); empty => signs-only (planets eliminated).
var _placements = {{ clock_placements_json|default:"{}"|safe }};
// The planet THIS gamer may place now (their position circle's turn), or ''.
var _placeable = overlay.dataset.clockPlaceable || '';
var SIGN_ORDER = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'];
// Sign-granularity placement → the sign's MIDPOINT degree (no degree precision
// yet); drawRim renders the glyph there.
function _midDeg(sign) { var i = SIGN_ORDER.indexOf(sign); return i < 0 ? 0 : i * 30 + 15; }
function _rimData() {
var planets = {};
Object.keys(_placements).forEach(function (p) {
planets[p] = { sign: _placements[p], degree: _midDeg(_placements[p]), retrograde: false };
});
return { planets: planets, aspects: [] };
}
function _csrf() {
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
// Place the active planet in the picked sign: POST, then adopt the server's
// authoritative placements + drop this gamer's affordance (their turn is
// done — turn handoff 5→1 is a later increment) + repaint.
function _onPickSign(sign) {
if (!_placeable || !overlay.dataset.clockPlaceUrl) return;
var planet = _placeable;
window.fetch(overlay.dataset.clockPlaceUrl, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': _csrf() },
body: 'planet=' + encodeURIComponent(planet) + '&sign=' + encodeURIComponent(sign),
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j) return;
_placements = j.placements || _placements;
_placeable = '';
var prompt = document.getElementById('id_clock_prompt');
if (prompt) prompt.parentNode.removeChild(prompt);
_paint();
}).catch(function () {});
}
// Phase-btn shed: drop #id_text_btn .active while the felt is up (sea-style);
// restore on close. The completed CAST SKY / DRAW SEA reopen btns stay lit.
@@ -52,20 +98,22 @@
_disabled = [];
}
// Paint/repaint the rim + dual graph at the felt's CURRENT box. The rim
// draws first and hands back its hub geometry; the map svg is then sized
// into the freed hub (2×hubR square, circle-clipped) so the wheel RINGS the
// tessellation — both svgs centre on the same point, so they stay
// concentric. No rim (SkyWheel absent) -> the map keeps its full-pane CSS
// size, the Step-1 behaviour (also the Jasmine fixture path).
// Paint/repaint the rim + dual graph at the felt's CURRENT box. The rim draws
// the placed planets (+ placement targets if it's this gamer's turn) and hands
// back its hub geometry; the map svg is then sized into the freed hub (2×hubR
// square, circle-clipped) so the wheel RINGS the tessellation — both svgs
// centre on the same point, so they stay concentric. No rim (SkyWheel absent)
// -> the map keeps its full-pane CSS size (the Jasmine fixture path).
function _paint() {
if (!window.SeedMap) return;
// NB: sky-wheel.js declares `const SkyWheel` at top-level script scope —
// a global LEXICAL binding, not a window property — so probe it with
// typeof, never window.SkyWheel (always undefined).
var geo = (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim)
? SkyWheel.drawRim(wheelSvg, _tableSky)
: null;
// NB: sky-wheel.js declares `const SkyWheel` at top-level script scope — a
// global LEXICAL binding, not a window property — so probe it with typeof,
// never window.SkyWheel (always undefined).
var geo = null;
if (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim) {
var opts = _placeable ? { placeable: _placeable, onPickSign: _onPickSign } : undefined;
geo = SkyWheel.drawRim(wheelSvg, _rimData(), opts);
}
if (geo) {
var side = Math.round(geo.hubR * 2);
svgEl.style.width = side + 'px';
@@ -83,9 +131,8 @@
// populates via 12 async SVG fetches fired at page parse by _sky_overlay.html.
// Every other draw site (sky.html, the SkyDrive applet, _sky_overlay) awaits
// that preload; the rim must too, else a cold-cache fast felt-open paints the
// sign slices WITHOUT their zodiac glyphs — and in the steady state (the room
// sky embedded server-side, so _fetchTableSky no-ops), no repaint ever heals
// it. preload() re-fetches are browser-cached + idempotent on the cache, so
// sign slices WITHOUT their zodiac glyphs — and nothing repaints to heal it.
// preload() re-fetches are browser-cached + idempotent on the cache, so
// kicking it once + repainting on resolve is the cheap established fix.
var _preloadKicked = false;
function _ensureGlyphs() {
@@ -96,15 +143,6 @@
}).catch(function () {});
}
function _fetchTableSky() {
if (_tableSky || _skyFetched || !overlay.dataset.tableSkyUrl) return;
_skyFetched = true; // one attempt per page — the signs-only frame stands on failure
window.fetch(overlay.dataset.tableSkyUrl)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { if (j) { _tableSky = j; _paint(); } })
.catch(function () {});
}
// Re-tessellate to fill the felt when its box changes (window resize /
// rotate). Observe the COL, not the map svg — _paint() sizes that svg
// itself, and self-observation would re-fire on every paint.
@@ -129,7 +167,6 @@
// Paint at the felt's current box (visibility:hidden retains layout, so the
// box is already the full pane), then keep it responsive to resize.
_paint();
_fetchTableSky();
_observeResize();
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
}
@@ -143,7 +180,7 @@
// PHASE btn #id_seed_map_btn lives in _room_hex_center.html (parsed BEFORE this
// overlay via _table_hex.html) -> bind directly, no defer. Trap T4 only bites
// _burger.html elements (included after); STEP 1 has no burger seed sub-btn.
// _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);
}());