Compare commits

...

159 Commits

Author SHA1 Message Date
Disco DeDisco
6248d95bf3 PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
PySwiss:
- calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs)
- /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone)
- aspects included in /api/chart/ response
- timezonefinder==8.2.2 added to requirements
- 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields)

Main app:
- Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033)
- Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross,
  confirmed_at/retired_at lifecycle (migration 0034)
- natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution,
  computes planet-in-house distinctions, returns enriched JSON
- natus_save view: find-or-create draft Character, confirmed_at on action='confirm'
- natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects,
  ASC/MC axes); NatusWheel.draw() / redraw() / clear()
- _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button
  with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill,
  NVM / SAVE SKY footer; html.natus-open class toggle pattern
- _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown,
  portrait collapse at 600px, landscape sidebar z-index sink
- room.html: include overlay when table_status == SKY_SELECT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
Disco DeDisco
44cf399352 added path filters to main + pyswiss pipelines; fixed deploy-pyswiss absolute path for deploy.sh
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:54 -04:00
Disco DeDisco
df2b353ebd split Woodpecker into separate main + pyswiss pipelines; added deploy-prod tag-triggered step for earthmanrpg.com cutover
Some checks failed
ci/woodpecker/push/pyswiss Pipeline failed
ci/woodpecker/push/main Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:55:49 -04:00
Disco DeDisco
3fd1f5e990 added django-cors-headers to pyswiss: allows browser fetch from *.earthmanrpg.me and localhost
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:08:32 -04:00
Disco DeDisco
02a7a0ef2e added deploy-pyswiss Woodpecker step to auto-deploy pyswiss droplet on push to main
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:59:08 -04:00
Disco DeDisco
cc2ab869f1 wired PySwiss microservice deployment: env-driven settings, gunicorn pinned, PYSWISS_URL in main settings, test-pyswiss CI stage
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:55:58 -04:00
Disco DeDisco
8c711ac674 added missing & crucial pyswiss.core.wsgi file pointing to DJANGO_SETTINGS_MODULE
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-13 20:47:18 -04:00
Disco DeDisco
b8af0041cc scaffolded PySwiss microservice: Django 6.0 project at pyswiss/ (core/ settings, apps/charts/), GET /api/chart/ + GET /api/charts/ views, EphemerisSnapshot model + migration, populate_ephemeris management command, 41 ITs (integrated + unit, all green); separate .venv with pyswisseph 2.10.3.2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:03:45 -04:00
Disco DeDisco
97ec2f6ee6 cropped & centered Sig icon placeholder in #id_tray_* apparatus
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-13 14:28:53 -04:00
Disco DeDisco
0a135c2149 implemented sig_confirm view: guards (403 unseated, 400 wrong phase, 400 not-all-ready), significator assignment from reservations, polarity_room_done + pick_sky_available broadcasts, SKY_SELECT advance when both polarities done
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:00:16 -04:00
Disco DeDisco
f1e9a9657b fixed SRG5-8 channels FTs: multi-browser sig-card OK flow now uses execute_async_script to iterate cards until a non-conflicting reserve succeeds (bypasses ElementNotInteractableException + 409 same-card conflicts); added wait_for_slow for 12s countdown in SRG8; added browser=None param to ChannelsFunctionalTest.wait_for/wait_for_slow
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:31:00 -04:00
Disco DeDisco
32d8d97360 wired PICK SKY server-side polarity countdown via threading.Timer (tasks.py); fixed polarity_done overlay gating on refresh; cleared sig-select floats on overlay dismiss; filtered Redact events from Most Recent applet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 00:34:05 -04:00
Disco DeDisco
df421fb6c0 added PICK SKY ready gate: SigReservation.ready + countdown_remaining fields, Room.SKY_SELECT status + sig_select_started_at, sig_ready + sig_confirm views, WS notifiers for countdown_start/cancel/polarity_room_done/pick_sky_available, migration 0031, PICK SKY btn in hex center at SKY_SELECT, tray cell 2 sig card placeholder; FTs SRG1-8 written (pending JS/consumer)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 01:17:24 -04:00
Disco DeDisco
3800c5bdad fixed attribution of .fa-hand-pointer cursor color scheme to ordering according to token-drop sequence instead of seat sequence; updates to accomodate this throughout apps.epic.models & .views, plus new apps.epic migration; assigned #id_sig_cursor_portal a z-index value corresponding to a high position but still beneath the #id_tray apparatus; minor semantic reordering of INSTALLED_APPS in core.settings
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-08 22:53:44 -04:00
Disco DeDisco
12d575a84b fixed seeding problem w. setUp helper causing same FTs to persistently fail
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-08 13:34:22 -04:00
Disco DeDisco
c14b6d7062 fixed some old data in two pipeline errors pointing to new Middle Arcana labels still as Minor Arcana
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-08 12:51:46 -04:00
Disco DeDisco
a7c5468cbc fixed failing channels FT related to Sig select; FT fix only, code written as intended
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-08 12:18:20 -04:00
Disco DeDisco
4da8750c60 fixed tooltip illegibility due to similar color to bg on .sig-overlay when data-polarity='gravity'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-08 11:57:44 -04:00
Disco DeDisco
cf40f626e6 Sig select: _card-deck.scss extract, WS cursor fixes, own-role indicators, role icon refresh
- New _card-deck.scss: sig select styles moved out of _room.scss + _game-kit.scss
- sig-select.js: 3 WS bug fixes — thumbs-up deferred to window.load (layout settled
  before getBoundingClientRect), hover cursor cleared for all cards on reservation
  (not just the reserved card), applyHover guards against already-reserved roles
- Own-role indicators: gamer now sees their own role-coloured card outline + thumbs-up
- Reservation glow: replaced blurry role+ninUser double-shadow with crisp 2px outline
- Gravity qualifier: Graven text set to --terUser (matches Leavened/--quiUser pattern)
- Role card SVGs refreshed; starter-role-Blank removed
- FTs + Jasmine specs extended for sig select WS behaviour
- setup_sig_session management command for multi-browser manual testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:52:49 -04:00
Disco DeDisco
99a826f6c9 FT: pin AppletMenuDismissTest to portrait viewport (800×1200)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Landscape layout activates sidebar CSS which causes #id_dash_content to
overlap the base-template h2 in CI headless Firefox, triggering
ElementClickInterceptedException. Portrait viewport sidesteps all
landscape breakpoints so the h2 sits safely above #id_dash_content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:26:35 -04:00
Disco DeDisco
51fe2614fa overruling other scss specificity in .btn-disabled 2026-04-07 00:43:26 -04:00
Disco DeDisco
56dc094b45 Jasmine: fix 2 failing specs, drop 5 always-pending touch specs
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- FYI btn is now btn-disabled when caution open; rename test to assert
  disabled click does NOT close caution (old toggle expectation was stale)
- Hover-resets-is-reversed: cloneNode post-init has no mouseenter listener
  (direct binding, not delegation); use mouseleave + re-enter on same card
- Remove 3 touch describe blocks (5 specs total); TouchEvent unavailable
  in desktop Firefox means they never ran; touch behaviour covered by FTs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:36:28 -04:00
Disco DeDisco
520fdf7862 Sig select: caution tooltip, FLIP/FYI stat block, keyword display
- TarotCard.cautions JSONField + cautions_json property; migrations
  0027–0029 seed The Schizo (number=1) with 4 rival-interaction cautions
  (Roman-numeral card refs: I. The Pervert / II. The Occultist, etc.)
- Sig-select overlay: FLIP (stat-block toggle) + FYI (caution tooltip)
  buttons; nav PRV/NXT portaled outside tooltip at bottom corners (z-70);
  caution tooltip covers stat block (inset:0, z-60, Gaussian blur);
  tooltip click dismisses; FLIP/FYI fully dead while btn-disabled;
  nav wraps circularly (4/4 → 1/4, 1/4 → 4/4)
- SCSS: btn-disabled specificity fix (!important); btn-nav-left/right
  classes; sig-caution-* layout; stat-face keyword lists
- Jasmine suite expanded: stat block + FLIP (5 specs), caution tooltip
  (16 specs) including wrap-around and disabled-button behaviour
- IT tests: TarotCardCautionsTest (5), SigSelectRenderingTest (8)
- Role-card SVG icons added to static/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:22:04 -04:00
Disco DeDisco
e2cc38686f XL landscape: revert tray to landscape style; fix sig-stage stretch
- Remove _tray.scss XL (≥1800px) portrait override block entirely
- _isLandscape() no longer returns false at ≥1800px — tray uses
  landscape slide-from-top at all wide landscape widths
- sig-stage: align-self: stretch (was center) so JS sizeSigCard()
  measures correct stage width; card size no longer collapses
- Position strip: horizontal row at top (was vertical column-reverse)
- sig-overlay/sig-stage/sig-deck-grid layout polish at 1100px/1800px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:11:24 -04:00
Disco DeDisco
0bcc7567bb XL landscape polish: btn-primary sizing, tray from right, footer bg, layout fixes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- .btn-xl removed; .btn-primary absorbs 4rem sizing (same as PICK SIGS/PICK ROLES)
- Landscape navbar .btn-primary: 3rem → 4rem to match base; XL stays 4rem (consistent)
- _button-pad.scss XL: base .btn ×1.2 (2.4rem); .btn-xl block deleted
- _tray.scss XL (≥1800px): portrait-style tray (slides from right, z-95)
- tray.js: _isLandscape() returns false at ≥1800px; portrait code paths run throughout
- Footer sidebar: background-color added so opaque footer masks tray sliding behind it
- Copyright .footer-container: bottom → top in landscape sidebar
- #id_room_menu: right: 2.5rem override in _room.scss XL block (cascade fix)
- navbar-text XL: 0.65rem × 1.2 = 0.78rem
- All landscape media queries: max-width: 1440px cutoff removed (already done prior)
- btn-xl class stripped from all 5 templates; test_navbar.py assertion updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 03:02:37 -04:00
Disco DeDisco
6654785f25 XL landscape breakpoint (≥1800px): double sidebar widths + scale content
- _base.scss: new @media (orientation:landscape) and (min-width:1800px) block —
  sidebars 4rem→8rem; navbar btn 3rem→5rem; brand h1 1.2rem→2.4rem; navbar-text
  0.65rem→1.3rem; footer icons 1.75rem→3rem; nav gap 3rem→4rem; footer-container
  0.55rem→0.85rem; container margins 4rem→8rem; h2 portrait-style (2rem, centred)
- _applets.scss: gear btn right 0.5rem→2.5rem; menus right 0.5rem→2rem at ≥1800px
- _game-kit.scss: kit btn right 0.5rem→2.5rem at ≥1800px
- _room.scss: sig-overlay padding-left 4rem→8rem at ≥1800px
- _tray.scss: tray wrap left/right 4rem→8rem at ≥1800px
- room.js: sizeSigModal right inset 64px→128px at ≥1800px viewport width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 01:41:18 -04:00
Disco DeDisco
99a69202b9 landscape layout: remove max-width cutoff; sig-select stage/grid polish
- All landscape @media queries: drop and (max-width: 1440px) — sidebar layout
  now activates for all landscape orientations regardless of viewport width
- _base.scss landscape container: add max-width:none to override the
  @media(min-width:1200px) rule and fill the full space between sidebars
- sig-select sig-deck-grid: landscape now 9×2 @ 3rem cards; 18×1 at ≥1100px
  (bumped from 992px to avoid last-card clip); card text scales with --sig-card-w
- sig-stat-block: flex:1→flex:0 0 auto with width:--sig-card-w so it matches
  preview card dimensions instead of stretching across the full stage
- room.js sizeSigModal: landscape card width clamped to [90px, 160px]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 01:30:31 -04:00
Disco DeDisco
55bb450d27 z-index audit + aperture fill + resize:end debounce + landscape sig-grid cap
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- #id_aperture_fill: position:fixed→absolute (clips to .room-page, avoids h2/navbar);
  z-index 105→90 (below blur backdrops at z-100); landscape override removed (inset:0 works both orientations)
- _base.scss: landscape footer z-index:100 (matches navbar); corrects unset z-index
- _room.scss: fix stale "navbar z-300" comment; landscape sig-deck-grid columns
  repeat(9,1fr)→repeat(9,minmax(0,90px)) to cap card size on wide viewports
- room.js: add resize:end listeners for scaleTable + sizeSigModal; new IIFE dispatches
  resize:end 500ms after resize stops so both functions re-measure settled layout
- tray.js: extract _reposition() from inline resize handler; wire to both resize and
  resize:end so tray repositions correctly after rapid resize or orientation change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 00:48:25 -04:00
Disco DeDisco
e28d55ad58 remove obsolete sig-select FTs (S1/S3/S4) based on old sequential 36-card design
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The new sig-select has two parallel 18-card overlays per polarity group (levity:
PC/NC/SC; gravity: BC/EC/AC) — no shared 36-card deck, no active-seat turn order.
S1 (36 cards), S3 (PC picks → deck shrinks → active advances to NC), and S4
(non-active seat blocked) all tested the old design and have been failing in CI.
S2 (seat display order) passed and is kept. Header comment updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:44:54 -04:00
Disco DeDisco
b110bb6d01 remove obsolete skipped tests; fix billboard applet menu containment; align landscape menus
Deleted skips:
- test_fan_next_button_advances_card (T11) + test_fan_remembers_position_on_reopen (T13):
  fan-nav nav button obstruction — deferred indefinitely, not worth tracking
- test_selected_sig_card_removed_from_deck_for_other_gamers (S5): card count
  mismatch in channels context — grand overhaul pending, obsolete with new sig-select
- Removed stale TODO comment about #id_inv_sig_card (element no longer exists)
- Dropped unused `import unittest` from test_room_sig_select.py

billboard applet menu fix: moved #id_billboard_applet_menu out of
#id_billboard_applets_container — container-type:inline-size was making the
container a containing block for fixed-position descendants, clipping the menu.

Landscape menu alignment: all applet menus now right:0.5rem (flush with gear/kit
buttons in the 4rem right sidebar); added #id_room_menu to the landscape rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:33:13 -04:00
Disco DeDisco
2892b51101 fix SigSelect Jasmine: return test API from IIFE; pend touch specs on desktop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
window.SigSelect was being clobbered by the IIFE's undefined return value
(var SigSelect = (function(){...window.SigSelect={...}}()) overwrites window.SigSelect
with undefined). Fixed by using return {} like RoleSelect does.

TouchEvent is not defined in desktop Firefox, so the 5 touch-related specs now
call pending() when the API is absent rather than throwing a ReferenceError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:14:56 -04:00
Disco DeDisco
871e94b298 sig-select landscape: stage card now visible; gear/kit btns in right sidebar column
sizeSigModal() no longer uses tray bottomInset in landscape (was over-shrinking the
modal, pushing the stage off-screen); fixed 60px kit-bag-handle clearance instead.
Gear btn + kit btn shifted into the 4rem right sidebar strip (right: 0.5rem) and
nudged down a quarter-rem so they clear the last card in the 9×2 grid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:02:32 -04:00
Disco DeDisco
c3ab78cc57 many deck changes, including pentacles to crowns, middle arcana, and major arcana fa icons 2026-04-05 22:32:40 -04:00
Disco DeDisco
c7370bda03 sig-select sprint: SigReservation model + sig_reserve view (OK/NVM hold); full sig-select.js rewrite with stage preview, WS hover cursors, reservation lock (must NVM before OK-ing another card — enforced server-side 409 + JS guard); sizeSigModal() + sizeSigCard() in room.js (JS-based card sizing avoids libsass cqw/cqh limitation); stat block hidden until OK pressed; mobile touch: dismiss stage on outside-grid tap when unfocused; 17 IT + Jasmine specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:01:23 -04:00
Disco DeDisco
a15d91dfe6 wrapped the _gatekeeper.html partial modal to split each function into four different panels; removed deviant landscape styling to unify it with default styling (much more robust now)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-05 19:10:02 -04:00
Disco DeDisco
fecb1fddca restored position circles to their top attr value to avoid old clipping-under-h2 issue; pushed down gatekeeper modal in room.html 2026-04-05 18:32:45 -04:00
Disco DeDisco
2028f1a544 more refinements to Earthman deck names and allegories; tweaks to navbar alignment in landscape media queries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-05 17:23:51 -04:00
Disco DeDisco
40c747a837 landscape navbar centering: reset portrait margin-right on .container-fluid + margin-left on .navbar-brand so sidebar contents align to horizontal centre; showGuard gains invertY option for modal-grid callers (role-select cards fly away from centre); gameboard.js showPortals gains viewport-half detection so game-kit tooltips show below when tokens are in upper half (landscape clip fix); position-strip top: 0; tighten gear-btn btn-abandon selector to #id_room_menu scope
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:54:03 -04:00
Disco DeDisco
40a55721ab major navbar overhaul: .btn-primary.btn-xl now reads CONT GAME and links to the user's most recently active game; log out functionality transferred to new BYE .btn-abandon abutting login spans; tooltips for each asserted via new FTs.test_navbar methods to appear w.in visible area 2026-04-05 16:00:52 -04:00
Disco DeDisco
d4518a0671 fixed jasmine & RoleSelectTest FT methods that were failing due to the Role card reordering in previous pipeline push
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-05 01:52:30 -04:00
Disco DeDisco
74f63a7721 rearranged Role select cards for final presentation ordering; unified Role select tooltip appearance; bottom row of Role select tooltips now appears below bottom row, not layered atop top row; clicking out of one Role select card tooltip and onto another Role select card specifically opens the next tooltip (former behavior made user click once to exit old tooltip, once more to open new one)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-05 01:23:20 -04:00
Disco DeDisco
bd3d7fc7bd role-select.js ensures Role select card stack disappears via WS upon conclusion of Role selection, w. if-conditional support from apps.epic.views; ensured border present on card-stack when .active in _room.scss; changed default #id_tray to unhidden, only hidden during Role select until Role selected; polished & unified Role .card-front, .card.back & .card-stack styling 2026-04-05 01:14:31 -04:00
Disco DeDisco
c00288e256 another #id_pick_sigs_btn IT fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-04 15:10:48 -04:00
Disco DeDisco
b5de96660a fix to pipeline involving new #id_pick_sigs_btn css selector
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-04 15:05:55 -04:00
Disco DeDisco
96bb05a4ba fixed some failing jasmine tests stemming from previous commit
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-04 14:54:54 -04:00
Disco DeDisco
4e07fcf38b fixed several animation & transition problems plaguing the inventory tray 2026-04-04 14:51:49 -04:00
Disco DeDisco
b74f8e1bb1 pick_sigs view + cursor polarity groups; game kit gear menu; housekeeping
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
roles_revealed WS event removed; select_role last pick now fires _notify_all_roles_filled() + stays in ROLE_SELECT; new pick_sigs view (POST /room/<uuid>/pick-sigs) transitions ROLE_SELECT→SIG_SELECT + broadcasts sig_select_started; room.html shows .pick-sigs-btn when all 6 roles filled; PICK SIGS btn absent during mid-selection; 11 new/modified ITs in SelectRoleViewTest + RoomViewAllRolesFilledTest + PickSigsViewTest

consumer: LEVITY_ROLES {PC/NC/SC} + GRAVITY_ROLES {BC/EC/AC}; connects to per-polarity cursor group (cursors_{id}_levity/gravity); receive_json routes cursor_move to cursor group; new handlers all_roles_filled, sig_select_started, cursor_move; CursorMoveConsumerTest (TransactionTestCase, @tag channels): levity cursor reaches fellow levity player, does not reach gravity player

game kit gear menu: #id_game_kit_menu registered in _applets.scss %applet-menu + fixed-position + landscape offset; id_gk_sections_container added to appletContainerIds in applets.js so OK submit dismisses menu; _game_kit_sections.html sections use entry.applet.grid_cols/grid_rows (was hardcoded 6); %applets-grid applied to #id_gk_sections_container (direct parent of sections, not outer wrapper); FT setUp seeds gk-* applets via get_or_create

drama test reorg: integrated/test_views.py deleted (no drama views); two test classes moved to epic/tests/integrated/test_views.py + GameEvent import added; drama/tests/unit/test_models.py → drama/tests/integrated/test_models.py; unit/ dir removed

login form: position:fixed + vertically centred in base styles across all breakpoints; 24rem width, text-align:center; landscape block reduced to left/right sidebar offsets; alert moved below h2; left-side position indicator slots 3/4/5 column order flipped via CSS data-slot selectors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:33:35 -04:00
Disco DeDisco
188365f412 game kit gear menu + login form UX polish; left-side position indicator flip
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
game kit: new Applet model rows (context=game-kit) for Trinkets, Tokens, Card Decks, Dice Sets via applets migration 0008; _game_kit_context() helper in gameboard.views; toggle_game_kit_sections view + URL; new _game_kit_sections.html (HTMX-swappable, visibility-conditional) + _game_kit_applet_menu.html partials; game_kit.html wired to gear btn + menu; Dice Sets now renders _forthcoming.html partial; 16 new green ITs in GameKitViewTest + ToggleGameKitSectionsViewTest

login form: .input-group now position:fixed + vertically centred (top:50%) across all breakpoints as default; landscape block reduced to left/right sidebar offsets only; form-control width 24rem, text-align:center; alert block moved below h2 in base.html; alert margin 0.75rem all sides; home.html header switches between Howdy Stranger (anon) and Dashboard (authed)

room.html position indicators: slots 3/4/5 (AC/SC/EC) column order flipped via SCSS data-slot selectors so .fa-chair sits table-side and label+status icon sit outward

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:49:48 -04:00
Disco DeDisco
824f35590b minor styling fixes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-03 14:55:37 -04:00
Disco DeDisco
43cb84e8f4 updated assertions in FTs.test_billboard to match the refined prose rendering from last commit
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 22:31:29 -04:00
Disco DeDisco
afe8e2b32c tweaked some prose templating in apps.drama.models; updated _applet-billboard-most-recent.html partial to mirror new row-by-row styling & html structure of room_scroll.html
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-02 15:38:44 -04:00
Disco DeDisco
ca38875660 fixed reverse chronological ordering in a pair of FTs clogging the pipeline; added ActivityPub to project; new apps.ap for WebFinger, Actor, Outbox views; apps.lyric.models now contains ap_public_key, ap_private_key fields + ensure_keypair(); new apps.lyric migration accordingly; new in drama.models are to_activity() w. JoinGate, SelectRole, Create compat. & None verb support; new core.urls for /.well-known/webfinger + /ap/ included; cryptography installed, added to reqs.txt; 24 new green UTs & ITs; in sum, project is now read-only ActivityPub node
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 15:22:04 -04:00
Disco DeDisco
8538f76b13 new core.middleware sets cookie for scroll timestamp view to local browser time, w. new corresponding tests in core.tests.UTs.test_middleware; apps.lyric.templatetags.lyric_extras determines timestamp format based on duration elapsed since timestamp; apps.bill.tests.ITs.test_views renamed, now also asserts scroll renders event body and time in columns
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-02 14:51:08 -04:00
Disco DeDisco
2a7d4c7410 new migrations in apps.epic for further refinements made to Pope card nomenclature
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 14:15:00 -04:00
Disco DeDisco
ed10e58383 small tweaks to h2 text-shadow attr rootvars values
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 00:00:14 -04:00
Disco DeDisco
b65cba5ed2 wrapped room table in .room-table-scene div, built styles and scripts to ensure table scales w. available viewport or aperture space
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-01 23:24:17 -04:00
Disco DeDisco
afe79f1a48 other minor styling fixes for gatekeeper modal, position circles 2026-04-01 23:12:49 -04:00
Disco DeDisco
0e5e39b0dc ensured .fa-ban next to empty seat changes to .fa-circle-check at the same time that .fa-chair glows & the pos circle fades out (i.e., when the gamer 'sits') not during or after the role card deposits itself in the tray; minor styling fixes for title h2, incl. text-shadow attr values when selected palette ends in *-light & opacity increases
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-01 22:11:43 -04:00
Disco DeDisco
4860b6ee2a real fix this time, rule overridden last time
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-01 15:41:19 -04:00
Disco DeDisco
c025a38709 small pipeline z-index hierarchy fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-01 15:30:20 -04:00
Disco DeDisco
581ea7e349 stopped card deck nav arrows from inheriting global .btn box-shadow attrs
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-01 15:14:19 -04:00
Disco DeDisco
596175cd1c refined _room.scss styles, incl. .launch-game-btn & .gate-slot
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-01 15:10:20 -04:00
Disco DeDisco
1aaf353066 renamed the Popes/0-card trumps from Earthman deck (feat. new apps.epic migrations to reseed); fixes to card deck horizontal scroll speed, game_kit.html, to make scrolling feel more natural 2026-04-01 14:45:53 -04:00
Disco DeDisco
441def9a34 skipped lowlevel grid cell assertion FT clogging pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-31 00:08:35 -04:00
Disco DeDisco
736b59b5c0 role-select UX: tray timing delays, seat/circle state polish, 394 ITs green
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- _animationPending set before fetch (not in .then()) — blocks WS turn advance during in-flight request
- _placeCardDelay (3s) + _postTrayDelay (3s) give gamer time to see each step; both zeroed by _testReset()
- .role-confirmed class: full-opacity chair after placeCard completes; server-rendered on reload
- Slot circles disappear in join order (slot 1 first) via count-based logic, not role-label matching
- data-active-slot on card-stack; handleTurnChanged writes it for selectRole() to read
- #id_tray_wrap not rendered during gate phase ({% if room.table_status %})
- Tray slide/arc-in slowed to 1s for diagnostics; wobble kept at 0.45s
- Obsolete test_roles_revealed_simultaneously FT removed; T8 tray FT uses ROLE_SELECT room
- Jasmine macrotask flush pattern: await new Promise(r => setTimeout(r, 0)) after fetch .then()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 00:01:04 -04:00
Disco DeDisco
a8592aeaec hex position indicators: chair icons at hex edge midpoints replace gate-slot circles
- Split .gate-overlay into .gate-backdrop (z-100, blur) + .gate-overlay modal (z-120) so .table-position elements (z-110) render above backdrop but below modal
- New _table_positions.html partial: 6 .table-position divs with .fa-chair, role label, and .fa-ban/.fa-circle-check status icons; included unconditionally in room.html
- New epic:room view at /gameboard/room/<uuid>/; gatekeeper redirects there when table_status set; pick_roles redirects there
- role-select.js: adds .active glow to position on selectRole(); swaps .fa-ban→.fa-circle-check in placeCard onComplete; handleTurnChanged clears stale .active from all positions
- FTs: PositionIndicatorsTest (5 tests) + RoleSelectTest 8a/8b (glow + check state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:31:05 -04:00
Disco DeDisco
8b006be138 demo'd old inventory area in room.html to make way for new content (hex table now centered in view); old test suite now targets Role card in #id_tray cells where appropriate, or skips Sig card select until aforementioned new feature deployed; new scripts & jasmine tests too; removed one irrelevant test case from apps.epic.tests.ITs.test_views.SelectRoleViewTest 2026-03-30 16:42:23 -04:00
Disco DeDisco
299a806862 fixed open #id_tray obscuring role select FTs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 23:46:23 -04:00
Disco DeDisco
fb782cf5ef maybe don't delete collectstatic static/tests/ dir 2026-03-29 23:39:03 -04:00
Disco DeDisco
224f5e2ad0 fixed Inferno palette --priUser rootvar hue
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 22:57:29 -04:00
Disco DeDisco
96379934d7 trying to reset to get this pipe clear
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 22:33:42 -04:00
Disco DeDisco
29a5658b01 'channels' tag now also moved to sequential FT group in pipeline; role-select.js ensures Tray.close() before turn advances so as not to obstruct next gamer selection; RoleSelectSpec.js asserrts this functionality
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 22:08:59 -04:00
Disco DeDisco
73135df7a6 skipped thorny failing FTs; separated out 'two-browser' tag to run before FTs–proper in pipeline for faster fail states
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 21:39:20 -04:00
Disco DeDisco
57f47cc77e another attempt to unclog pipeline; this time a slight sleep timeout used to accomodate headless browser resize flush
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 21:11:24 -04:00
Disco DeDisco
5d21e79be5 more headless patches to address pipeline clog; 'two-browsers' may not have been doing anything before
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 20:41:26 -04:00
Disco DeDisco
ff0883002b added one more FT to the 'two-browser' tag'; for real this might actually unclog the pipeline this time
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 20:04:57 -04:00
Disco DeDisco
7f927741d4 oops, forgot the normal .grid-cell styles, had only updated the landscape media query & not the base condition
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 19:46:59 -04:00
Disco DeDisco
3bf48546e3 tagged some further tests as 'two-browser' in persisting attempt to unclog pipeline fails; _tray.scss .grid-cell border-color changed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 19:43:48 -04:00
Disco DeDisco
6817323f8e further tweaked sepia palette; shored up TestTray for headless browser pipeline testing
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 19:10:42 -04:00
Disco DeDisco
11283118d6 small rootvars hue changes to sepia palette (should rename to 'cedar'); new FTs skipped via unittest to try to unclog pipeline fails
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 18:35:20 -04:00
Disco DeDisco
6c91ec0385 expanded margin of position spots on gatekeeper; cleaned up #id_tray scripts & styles
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 15:22:00 -04:00
Disco DeDisco
39db59c71a styles related to #id_tray & apparatus separated out into _tray.scss; new tray.js computes the cell size of the tray grid for item organization; room.html now sports the grid as a separate div so as not to interfere w. tray styling or size; new tests in FTs.test_room_tray 2026-03-29 13:36:44 -04:00
Disco DeDisco
5f643350c5 unskipped certain passing FTs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 01:21:33 -04:00
Disco DeDisco
ab41797e57 refined styling for #id_tray & .table-hex, which now mirror ea. other visually as parts of a befelted table
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 00:48:19 -04:00
Disco DeDisco
e35855f472 fixed wobble timing condition to be slow enough for headless firefox to catch it
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-28 23:50:08 -04:00
Disco DeDisco
0e5805efd2 'two-browser' tag separates out tests that run multiple browsers in pipeline so that --parallel tests don't interfere w. loading of one or more of such windows; both FTs.test_sharing & woodpecker.yaml updated accordingly
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-28 23:14:31 -04:00
Disco DeDisco
de99b538d2 FTs.test_room_tray.TrayTest now contains setUp() helper to set default window size for methods which don't otherwise define a specific media query; several new Jasmine methods test drawer snap-to-close & wobble functionality
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-28 22:50:43 -04:00
Disco DeDisco
c08b5b764e new landscape styling & scripting for gameroom #id_tray apparatus, & some overall scripting & styling like wobble on click-to-close; new --undUser & --duoUser rootvars universally the table felt values; many new Jasmine tests to handle tray functionality 2026-03-28 21:23:50 -04:00
Disco DeDisco
d63a4bec4a new .active styling to #id_tray_btn, _handle & _grip whenever drawer is open 2026-03-28 19:06:09 -04:00
Disco DeDisco
b35c9b483e seat tray: tray.js, SCSS, FTs, Jasmine specs
- new apps.epic.static tray.js: IIFE with drag-open/click-close/wobble
  behaviour; document-level pointermove+mouseup listeners; reset() for
  Jasmine afterEach; try/catch around setPointerCapture for synthetic events
- _room.scss: #id_tray_wrap fixed-right flex container; #id_tray_handle +
  #id_tray_grip (box-shadow frame, transparent inner window, border-radius
  clip); #id_tray_btn grab cursor; #id_tray bevel box-shadows, margin-left
  gap, height removed (align-items:stretch handles it); tray-wobble keyframes
- _applets.scss + _game-kit.scss: z-index raised (312-318) for primacy over
  tray (310)
- room.html: #id_tray_wrap + children markup; tray.js script tag
- FTs test_room_tray: 5 tests (T1-T5); _simulate_drag via execute_script
  pointer events (replaces unreliable ActionChains drag); wobble asserts on
  #id_tray_wrap not btn
- static_src/tests/TraySpec.js + SpecRunner.html: Jasmine unit tests for
  all tray.js branches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:52:46 -04:00
Disco DeDisco
30ea0fad9d fixed sig-select deck styling, room.html aperture styling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-25 15:50:57 -04:00
Disco DeDisco
62d5c738f9 fixed .sig-card reference in failing IT
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-25 11:08:19 -04:00
Disco DeDisco
f0f419ff7e offloaded Significator FTs into FTs.test_room_sig_select; new sig-select.js imported into room.html; new apps.epic.consumers & .views, ITs to confirm functionality
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-25 11:03:53 -04:00
Disco DeDisco
0494710ce0 skipped a FT clogging the pipeline in need of js not yet built
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-25 10:26:42 -04:00
Disco DeDisco
713e24863d fixed two failing pipeline errors due to significator select; skipped two others
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-25 02:25:59 -04:00
Disco DeDisco
b3bc422f46 new migrations in apps.epic for .models additions, incl. Significator select order (= Start Role seat order), which cards of whom go into which deck, which are brought into Sig select; new select-sig urlpattern in .views; room.html supports this stage of game now
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-25 01:50:06 -04:00
Disco DeDisco
c0016418cc hopefully plugged pipeline fail for FT to assert stock card deck version; 11 new test_models ITs & 12 new test_views ITs in apps.epic.tests 2026-03-25 01:30:18 -04:00
Disco DeDisco
4d52c4f54d reordered Pope cards in Earthman deck; addressed two pipeline errors concerning card deck via setUp helper
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-25 01:08:12 -04:00
Disco DeDisco
db1608fa38 Earthman card naming conventions overhauled: group-relative Arabic ordinals throughout (Implicit/Explicit Virtues, Classical/Absolute Elements, Zodiac, Wanderers, Popes); group prefix + title split across two lines in fan modal via name_group/name_title model properties; 4th suit migrated COINS → PENTACLES w. fa-star icon on both decks; pip names 2–10 spelled out; Classical Element 2 renamed Earth → Stone; migrations 0012–0015
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-25 00:46:48 -04:00
Disco DeDisco
4728cde771 Jacks & Cavaliers replaced in Earthman deck w. Maids & Jacks; numerals or numbers + symbols added to cards; migrations made in apps.epic to rename cards; _tarot_fan.html partial updated accordingly 2026-03-25 00:24:26 -04:00
Disco DeDisco
2f6fc1ff20 horizontal scrolling where applicable can now be done via vertical mousewheel movement 2026-03-25 00:05:52 -04:00
Disco DeDisco
9698d70164 scroll buffer in room_scroll.html aperture fine-tuned so that 'What happens next…?' can always be reached by scrolling on a fresh page reload, even if the user was at the very end of the scroll
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-24 23:47:17 -04:00
Disco DeDisco
7370fd611f tolltips added to card deck; supported in game-kit.js, _wallet-tokens.js (we should rename this for broader concept than just wallet) 2026-03-24 23:29:32 -04:00
Disco DeDisco
f5a5ed9d8d currently equipped card deck & placeholder for dice set added to kit bag; scrollability of tokens added to styling; equipped_deck added to apps.dash.views.kit_bag; html structure added to templates/core/_partials/_kit_bag_panel.html; two new test cases added to FTs.test_game_kit.GameKitTest 2026-03-24 23:18:04 -04:00
Disco DeDisco
a5d71925fc game kit page: four 6×3 applets (trinkets, tokens, card decks, dice sets) with applet grid; tarot fan modal with coverflow, sessionStorage position memory, and 403 guard on locked decks; unlocked_decks M2M on User with backfill migration; game kit icon wrap fix; tarot_deck.html moved to gameboard/ per template dir convention (now documented in CLAUDE.md); FTs 6–13, 2 new ITs; 360 passing [log Co-Authored-By: Claude Sonnet 4.6] 2026-03-24 22:57:12 -04:00
Disco DeDisco
b03ba09b65 new migrations in apps.lyric ensure new users start only w. Earthman card deck unlocked; FTs.test_component_cards_tarot.py updated to assert that user specifically has Fiorentine deck unlocked as well
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-24 22:34:50 -04:00
Disco DeDisco
befa61e1e9 several fixes, incl. location of templates/apps/epic/tarot_deck.html to apps/gameboard/tarot_deck.html; added this convention to CLAUDE.md; Game Kit applet items now plentiful enough to bother w. text wrapping in _gameboard.scss; unlocked_decks differentiates from equipped_deck in apps.lyric.models; new migrations accordingly; apps.gameboard.views accounts for only unlocked_decks in deck_variants now; apps.epic.views redirected to new tarot_deck.html location 2026-03-24 22:25:25 -04:00
Disco DeDisco
15ac3216ff step 17 complete: game kit deck variant cards with hover-equip mini-tooltip; DeckVariant.short_key property for template ids; equip-deck view and url in gameboard; gameboard.js unified for decks and trinkets, portals now inline-display-controlled for FT compatibility; billboard scroll fix: pos captured at event time, rAF guard prevents spurious debounce reset on first visit; 3 new ITs for Earthman deck defaults, Fiorentine not auto-assigned; gameboard IT updated for deck variant cards [git log Co-Authored-By: Claude Sonnet 4.6]
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-24 21:52:57 -04:00
Disco DeDisco
2896efa8e0 long overdue fix to last pipeline push, where scroll position did not persist across sessions 2026-03-24 21:36:02 -04:00
Disco DeDisco
588358a20f added default Earthman 108-card tarot deck, 78-card Minchiate Fiorentine deck, admin tests for each; DeckVariant model governs deck toggle; ran new migrations for apps.epic, apps.lyric; seeded DeckVariant migration to ensure Earthman is default deck; added min. tarot url; most new FTs passing 2026-03-24 21:07:01 -04:00
Disco DeDisco
11c85d56d1 fixed last of scroll position view in portrait mode to remember & display user's last line at bottom of applet viewport
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-24 19:11:27 -04:00
Disco DeDisco
8bab26e003 scroll position save fix attempt no. 1 feat. 'What happens next…?' text at bottom of scroll; buffer added to scroll, accounter for in FTs 2026-03-24 19:02:29 -04:00
Disco DeDisco
bc78d2c470 offloaded templates/core/_partials/_forthcoming.html to inject in any applet or other feature under construction; used immediately in Contacts billboard applet; styles updated accordingly 2026-03-24 18:40:16 -04:00
Disco DeDisco
2447315fd3 forgot to add latest migrations from apps.drama 2026-03-24 17:45:50 -04:00
Disco DeDisco
cde231d43c billscroll should now remember user's position across devices 2026-03-24 17:44:34 -04:00
Disco DeDisco
a0f8aeb791 similar pseudo-applet styling added to _scroll.html 2026-03-24 17:31:51 -04:00
Disco DeDisco
2ca4e9d39f fixed #id_gear_btn styling on billboard.html; removed redundant padding from %billboard-page-base 2026-03-24 17:22:49 -04:00
Disco DeDisco
c71f4eb68c styled more of Most Recent applet, allowing for scrolling of 36 most recent events and Load More link 2026-03-24 17:19:09 -04:00
Disco DeDisco
189d329e76 new applet structure for apps.billboard, incl. My Scrolls, Contacts & Most Recent applets; completely revamped _billboard.scss, tho some styling inconsistencies persist; ensured #id_billboard_applets_container inherited base styles found in _applets.scss; a pair of new migrations in apps.applets to support new applet models & fields; billboard gets its first ITs, new urls & views; pair of new FT classes in FTs.test_billboard 2026-03-24 16:46:46 -04:00
Disco DeDisco
18898c7a0f several fixes to payment applet styling & script
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-24 14:13:44 -04:00
Disco DeDisco
f347af7eff reordered footer tab icons; addressed pipeline layout FT error
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-24 00:49:04 -04:00
Disco DeDisco
e59d5fd4c0 committing uncommitted styling changes from static_src/scss/
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-24 00:28:50 -04:00
Disco DeDisco
62f6c27806 many styling changes to applets and palettes applet esp.; all applets seeded w. < 3rows bumped to 3 w. new migration in apps.applets; setting palette no longer reloads entire page, only preset background-color vars; two new ITs in apps.dash.tests.ITs.test_views.SetPaletteTest to ensure dash.views functionality fires; unified h2 applet title html structure & styled its text vertically to waste less applet space
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-24 00:26:22 -04:00
Disco DeDisco
cc02419e8d actually bubbles up original error w.o pickling TypeErrors wrapping it
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-23 22:56:10 -04:00
Disco DeDisco
c331e72de6 fixed some styling issues that prevented the enter email for login field from displaying on landscape breakpoints
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-23 20:07:59 -04:00
Disco DeDisco
a1f8d294a3 several more styling fixes to get landscape FTs to pass pipeline
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-23 19:50:08 -04:00
Disco DeDisco
5607f70852 added type='button' to both guard portal btns so firefox won't normalize to type='submit'; fixed several FTs for new click-guard functionality on Role card select & room gear menu DEL & BYE btns; several restorations to landscape breakpoint incl. logged-ion display_name, copyright info; provided title to room_scroll.html; a slurry of other minor fixes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-23 19:31:57 -04:00
Disco DeDisco
eecb6c2be6 ensured footer was pinned to bottom of page for new-ish billboard.html & room_scroll.html pages; introduced mobile landscape layout, incl. leftward 'navbar', rightward 'footer'; ensured z-index primacy of #id_kit_btn, which would here appear behind the kit bar when open; other fixes introduced by problems stemming largely from new landscape styling
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-23 01:06:14 -04:00
Disco DeDisco
2fd3ec9ab2 added header_text to billboard.html; restored L+R .container padding after last fix (still 0 T+B)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-22 15:06:54 -04:00
Disco DeDisco
cad3744a57 gameboard gear menu clipping under footer aperture finally RESOLVED; .container padding attr true cause behind two red herrings, #id_footer background attr & %applets-grid mask-image attr; latter still pared down to open more viewable space in applet container aperture
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-22 14:36:02 -04:00
Disco DeDisco
ffb374c81c updated palette-classes ending in .*-light to switch the rgb values of their tooltip background-color attrs from black to white (better accessibility); changed 'monochrome-light' & its cognates to 'oblivion-light', since it's hardly monochrome at all anymore
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-21 23:57:05 -04:00
Disco DeDisco
3b905e0436 moved _scroll.html from templates/apps/drama/ to templates/core/_partials/; updated templates/apps/billboard/room_scroll.html include tag to point there
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-21 23:39:47 -04:00
Disco DeDisco
f1b5ba2a71 given flaky --parallel FT pipeline fails, new fix in core.runner, incl. _Py313SafeRemoteTestRunner, so that errors bubbling up don't read as generic TypeError: cannot pickle 'traceback' object
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-21 23:08:21 -04:00
Disco DeDisco
184854a2de new apps.epic.tests.integrated.test_views.PickRolesViewTest.test_pick_roles_idempotent_no_duplicate seats passes w. duplicate no-op post ensures single line addition to apps.epic.views.pick_roles prevents ea. position from drawing twice ea. turn during Role Select phase at table; new assertions in FTs.test_room_role_select.RoleSelectChannelsTest.test_turn_passes_after_selection for same
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-21 22:22:06 -04:00
Disco DeDisco
f5c2cf4636 in role-select.js, selectRole() runs in more precise ordering to ensure card hand for role selection passes to the next gamer after a selection is made; previous bug allowed multiple cards at a single gamer position, which prevented the card hand from making a circuit around the table before depletion; backend fixes including to apps.epic.views.select_role; +2 FTs & +1 IT asserts these features
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-21 14:33:06 -04:00
Disco DeDisco
91e0eaad8e new DRAMA & BILLBOARD apps to start provenance system; new billboard.html & _scroll.html templates; admin area now displays game event log; new CLAUDE.md file to free up Claude Code's memory.md space; minor additions to apps.epic.views to ensure new systems just described adhere to existing game views
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-19 15:48:59 -04:00
Disco DeDisco
5a811d0079 plugged some test coverage lacunae, incl. tests for release_slot for the Carte Blanche; select_role for ROLE_CHOICES & ROLE_SELECT; equip_trinket non-POST paths; & tooltip_shoptalk for the Tithe Token
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-19 00:00:00 -04:00
Disco DeDisco
8c2a5d24ec updated .fa-ban icon to update via js & ws; changed taken_roles (or its cognates) everywhere to starter_roles, as 'taken' will be used in respect to roles thru-out entire game, not just this seat-determining phase of Role Select; patched up chosen cards not disappearing upon previous gamer choice, & a try,except that catches attempts to select one anyway w. a 409 & optimistic card rollback; new IT confirms this 409
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 23:14:53 -04:00
Disco DeDisco
4f076165ef removed console ws closed warning on event.wasClean
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 22:20:51 -04:00
Disco DeDisco
3a87a17017 Dockerfile updated to run uvicorn worker class to support asgi (was still gunicorn & wsgi)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 22:03:10 -04:00
Disco DeDisco
4e63323019 a pair of small fixes to infra/nginx.conf.j2, to ensure WebSockets functionality; & to role-select.js, to fix the inventory from not updating to that of the new position when a gamer passed the Role cards to the next position when he also occupies that position; separate inventories now ensured
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 21:42:59 -04:00
Disco DeDisco
8b2c4e1bdc imported tag to tag 'channels' on RoleSelectChannelsTest to see if the pipeline can get past more similar complications
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 21:11:07 -04:00
Disco DeDisco
10d717a3ba removed parallel worker subprocess fail screendump req'ment, so not to break the --parallel FT run
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-18 20:49:44 -04:00
Disco DeDisco
e9f50810da imported itertools to base FT fns to support --parallel core split from last push
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-18 20:42:54 -04:00
Disco DeDisco
67697fa90e established parallel CI pipeline for quicker testing after DO droplet upsizing; ensured gamearray (docker) and gamearray_celery services restart automatically when not purposefully powered off
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-18 20:24:02 -04:00
Disco DeDisco
97b406c7e0 seat-card-arc fan driven by data-card-count (0/1/2/3); active arc glows/floats;dual CSS class aliases: .table-seat/.table-position, .seat-portrait/.position-portrait etc.; seat_role_counts context var; room.html arcs populated server-side on load; per-position inventory model: assigned_seats=[] always; JS clears #id_inv_role_card on turn_changed; _notify_turn_changed includes seat_counts (str keys) for observer arc sync; selectRole() increments active arc immediately + disables stack to prevent double-picks; room.js WS auto-reconnect with exponential backoff (1s->30s); _applet_menu.html extracted from gameboard/_applets.html and wallet/_applets.html (menu now sibling of applets container, not nested inside it); partial fix for mask clip bug — deferred; commented out footer background-gradient (revealed underlying clip bug); removed landscape .room-page .gear-btn bottom override; FT 3d: assert arc data-card-count=1 on re-entry instead of inventory cards
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-17 15:48:38 -04:00
Disco DeDisco
568497d09d duplicate browsers to simulate multiple gamers in test envs now handle headless firefox in pipeline correctly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-17 01:00:15 -04:00
Disco DeDisco
1558bb02b4 fixed box-shadow attr on token equip assignation to .token-panel instead of .token-rails, where it belonged
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-17 00:39:19 -04:00
Disco DeDisco
01de6e7548 Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped 2026-03-17 00:24:23 -04:00
Disco DeDisco
c9defa5a81 daphne added to dependencies; still reliant on uvicorn, as the former is now used solely as a channels testing req'ment; new consumer model in apps.epic.consumers to handle _gatekeeper partial functionality, permitting access to room once token costs met; new .routing urlpattern to accomodate; new tests.integrated.test_consumer IT cases ensure this functionality 2026-03-16 18:44:06 -04:00
Disco DeDisco
462155f07b fixed some UX inconsistencies in gatekeeper
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-16 01:04:52 -04:00
Disco DeDisco
fa46fc18d7 fixes to kit bag dialog & mini-tooltip presence which stymied a pair of FTs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-16 00:30:33 -04:00
Disco DeDisco
4239245902 add Carte Blanche trinket: equip system, gatekeeper multi-slot, mini tooltip portal; new token type Token.CARTE ('carte') with fa-money-check icon; migrations 0010-0012:
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
CARTE type, User.equipped_trinket FK, Token.slots_claimed field; post_save signal sets
equipped_trinket=COIN for new users, PASS for staff; kit bag now shows only the equipped
trinket in Trinkets section; Game Kit applet mini tooltip portal shows Equipped or Equip
Trinket per token; AJAX POST equip-trinket id updates equippedId in-place; equip btn
now works for COIN, PASS, and CARTE (data-token-id added to all three); Gatekeeper CARTE
flow: drop_token sets current_room (no slot reserved); each empty slot up to
slots_claimed+1 gets a drop-token-btn; slots_claimed high-water mark advances on fill,
never decrements; highest CARTE-filled slot gets NVM (release_slot); token_return_btn
resets current_room + slots_claimed + un-fills all CARTE slots; gate_status always returns
full template so launch-game-btn persists via HTMX when gate_status == OPEN; room.html
includes gatekeeper when GATHERING or OPEN; new FT test_trinket_carte_blanche.py (2
tests, both passing); 299 tests green
2026-03-16 00:07:52 -04:00
Disco DeDisco
b49218b45b significant palette overhaul, w. addition of +3 new palettes; new swatch preview appearance; expanded palette toggle functionality; repaired test suite accordingly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-15 18:52:09 -04:00
Disco DeDisco
ace9a4888e updated description text on Backstage Pass to more accurately describe its unlimited capacity
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-15 17:54:58 -04:00
Disco DeDisco
435bec7988 confined htmx polling on _gatekeeper.html to permit continuous typing; previous behavior kicked mobile user out of keyboard input every 3s period
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-15 17:36:42 -04:00
Disco DeDisco
12146037f0 now that like token_types stack in UX, _0 removed from 4 test methods that previously looked for specific token's ID
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-15 16:57:24 -04:00
Disco DeDisco
ff7b71792f narrow desktop breakpoint constraint relaxed somewhat to accomodate more fringe-case window aspect ratios; #id_gear_btn now, like #id_kit_btn, restyles to contain --quaUser rgb value when menu is active; dashboard.html include ordering switched for #id_dash_applet_menu & #id_gear_btn, to fix an issue causing the menu to overlay the btn instead of the other way around
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-15 16:39:14 -04:00
Disco DeDisco
2e24175ec8 new apps.epic app migrations for token expiration & cooldown; reject token renamed to return token everywhere; new mapps.epic.models & .views for expiration & cooldown; new apps.dash.views to manage stacking of like Token types not just in the kit bag but in the Gameboard's Game Kit applet & in the Dashwallet's Tokens applet; Free Tokens now display correctly in kit bag; apps.lyric.admin now ensures superuser cannot grant Free Tokens without an expiration date; corresponding tests in .tests.integrated.test_admin.TokenAdminFormTest; screendumps occurring for every test, regardless of passfail status, after one fail fixed in FTs.base; FTs.test_gatekeeper.GameKitInsertTest.test_free_token_insert_via_kit_consumed_on_confirm, for test purposes only, ensures starting Free Token deleted before fresh one assigned w. full 7d expiration battery 2026-03-15 16:08:34 -04:00
249 changed files with 39296 additions and 766 deletions

5
.gitignore vendored
View File

@@ -10,9 +10,8 @@
*.pyc *.pyc
__pycache__/ __pycache__/
local_settings.py local_settings.py
db.sqlite3 *.sqlite3
db.sqlite3-journal *.sqlite3-journal
container.db.sqlite3
media media
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/

View File

@@ -22,6 +22,33 @@ steps:
- python manage.py test apps - python manage.py test apps
when: when:
- event: push - event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: test-two-browser-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
environment:
HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
STRIPE_SECRET_KEY:
from_secret: stripe_secret_key
STRIPE_PUBLISHABLE_KEY:
from_secret: stripe_publishable_key
commands:
- pip install -r requirements.txt
- cd ./src
- python manage.py collectstatic --noinput
- python manage.py test functional_tests --tag=two-browser
- python manage.py test functional_tests --tag=channels
when:
- event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: test-FTs - name: test-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
@@ -37,9 +64,13 @@ steps:
- pip install -r requirements.txt - pip install -r requirements.txt
- cd ./src - cd ./src
- python manage.py collectstatic --noinput - python manage.py collectstatic --noinput
- python manage.py test functional_tests - python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser
when: when:
- event: push - event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: screendumps - name: screendumps
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
@@ -48,6 +79,10 @@ steps:
when: when:
- event: push - event: push
status: failure status: failure
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: build-and-push - name: build-and-push
image: docker:cli image: docker:cli
@@ -61,8 +96,13 @@ steps:
when: when:
- branch: main - branch: main
event: push event: push
path:
- "src/**"
- "requirements.txt"
- "Dockerfile"
- ".woodpecker/main.yaml"
- name: deploy - name: deploy-staging
image: alpine image: alpine
environment: environment:
SSH_KEY: SSH_KEY:
@@ -76,4 +116,22 @@ steps:
when: when:
- branch: main - branch: main
event: push event: push
path:
- "src/**"
- "requirements.txt"
- "Dockerfile"
- ".woodpecker/main.yaml"
- name: deploy-prod
image: alpine
environment:
SSH_KEY:
from_secret: deploy_ssh_key
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
when:
- event: tag

33
.woodpecker/pyswiss.yaml Normal file
View File

@@ -0,0 +1,33 @@
steps:
- name: test-pyswiss
image: python:3.13-slim
environment:
SWISSEPH_PATH: /tmp/ephe
commands:
- apt-get update -qq && apt-get install -y -q gcc g++
- pip install -r pyswiss/requirements.txt
- cd ./pyswiss
- python manage.py test apps.charts
when:
- event: push
path:
- "pyswiss/**"
- ".woodpecker/pyswiss.yaml"
- name: deploy-pyswiss
image: alpine
environment:
SSH_KEY:
from_secret: pyswiss_deploy
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh -o StrictHostKeyChecking=no discoman@167.172.154.66 /home/discoman/deploy.sh
when:
- branch: main
event: push
path:
- "pyswiss/**"
- ".woodpecker/pyswiss.yaml"

161
CLAUDE.md Normal file
View File

@@ -0,0 +1,161 @@
# EarthmanRPG — Project Context
Originally built following Harry Percival's *Test-Driven Development with Python* (3rd ed., complete through ch. 25). Now an ongoing game app — EarthmanRPG — extended well beyond the book.
## Browser Integration
**Claudezilla** is installed — a Firefox extension + native host that lets Claude observe and interact with the browser directly.
### Tool names
Tools are available as `mcp__claudezilla__firefox_*`, e.g.:
- `mcp__claudezilla__firefox_screenshot` — capture current tab
- `mcp__claudezilla__firefox_navigate` — navigate to URL
- `mcp__claudezilla__firefox_get_page_state` — structured JSON (faster than screenshot)
- `mcp__claudezilla__firefox_create_window` — open new tab (returns `tabId`)
- `mcp__claudezilla__firefox_diagnose` — check connection status
- `mcp__claudezilla__firefox_set_private_mode` — disable private mode to use session cookies
All tools require a `tabId` except `firefox_create_window` and `firefox_diagnose`.
### If tools aren't available in a session
MCP servers load at session startup only. **Start a new Claude Code conversation** (hit "+" in the sidebar) — no need to reboot VSCode, just open a fresh chat. Always call `firefox_diagnose` first to confirm the connection is live.
### Correct startup sequence
1. Firefox open with Claudezilla extension active (native host must be running)
2. Open a new Claude Code conversation → tools appear as `mcp__claudezilla__firefox_*`
3. Call `firefox_diagnose` to confirm before depending on any tool
### Setup (already done — for reference)
The native messaging host requires a `.bat` wrapper on Windows (Firefox can't execute `.js` directly):
- Wrapper: `E:\ClaudeLibrary\claudezilla\host\claudezilla.bat` — contains `@echo off` / `node "%~dp0index.js" %*`
- Manifest: `C:\Users\adamc\AppData\Roaming\claudezilla\claudezilla.json` — points to the `.bat` file
- Registry: `HKCU\SOFTWARE\Mozilla\NativeMessagingHosts\claudezilla` → manifest path
- MCP server: registered in `~/.claude.json` (NOT `~/.claude/settings.json` or `~/.claude/mcp.json`) — use the CLI to register:
```
claude mcp add --scope user claudezilla "D:/Program Files/nodejs/node.exe" "E:/ClaudeLibrary/claudezilla/mcp/server.js"
```
- Permission: `mcp__claudezilla__*` in `~/.claude/settings.json` `permissions.allow`
**Config file gotcha:** The Claude Code CLI and VSCode extension read user-level MCP servers from `~/.claude.json` (home dir, single file) — NOT from `~/.claude/settings.json` or `~/.claude/mcp.json`. Always use `claude mcp add --scope user` to register; never hand-edit. Verify registration with `claude mcp list`.
**BOM gotcha:** PowerShell writes JSON files with a UTF-8 BOM, which causes `JSON.parse` to throw. Never use PowerShell `Set-Content` to write any Claude config JSON — use the Write tool or the CLI instead.
Native host: `E:\ClaudeLibrary\claudezilla\host\`. Extension: `claudezilla@boot.industries`.
## Stack
- **Python 3.13 / Django 6.0 / Django Channels** (ASGI via Daphne/uvicorn)
- **Celery + Redis** (async email, channel layer)
- **django-compressor + SCSS** (`src/static_src/scss/core.scss`)
- **Selenium** (functional tests) + Django test framework (integration/unit tests)
- **Stripe** (payment, sandbox only so far)
- **Hosting:** DigitalOcean staging (`staging.earthmanrpg.me`) | CI: Gitea + Woodpecker
## Project Layout
The app pairs follow a tripartite structure:
- **1st-person** (personal UX): `lyric` (backend — auth, user, tokens) · `dashboard` (frontend — notes, applets, wallet UI)
- **3rd-person** (game table UX): `epic` (backend — rooms, gates, role select, game logic) · `gameboard` (frontend — room listing, gameboard UI)
- **2nd-person** (inter-player events): `drama` (backend — activity streams, provenance) · `billboard` (frontend — provenance feed, embeddable in dashboard/gameboard)
```
src/
apps/
lyric/ # auth (magic-link email), user model, token economy
dashboard/ # Notes (formerly Lists), applets, wallet UI [1st-person frontend]
epic/ # rooms, gates, role select, game logic [3rd-person backend]
gameboard/ # room listing, gameboard UI [3rd-person frontend]
drama/ # activity streams, provenance system [2nd-person backend]
billboard/ # provenance feed, embeds in dashboard/gameboard [2nd-person frontend]
api/ # REST API
applets/ # Applet model + context helpers
core/ # settings, urls, asgi, runner
static_src/ # SCSS source
templates/
functional_tests/
```
### Template directory convention
Templates live under `templates/apps/<frontend-app>/`, not under the backend app that owns the view logic. Specifically:
- `lyric/` views → `templates/apps/dashboard/`
- `epic/` views → `templates/apps/gameboard/`
- `drama/` views → `templates/apps/billboard/`
Backend apps (`lyric`, `epic`, `drama`) have **no** `templates/` subdirectory.
## Dev Commands
```bash
# Dev server (ASGI — required for WebSockets; no npm/webpack build step)
cd src
uvicorn core.asgi:application --host 0.0.0.0 --port 8000 --reload --app-dir src
# Integration + unit tests only (from project root — targets src/apps, skips src/functional_tests)
python src/manage.py test src/apps
# Functional tests only
python src/manage.py test src/functional_tests
# All tests (integration + unit + FT)
python src/manage.py test src
```
### Multi-user manual testing — `setup_sig_session`
`src/functional_tests/management/commands/setup_sig_session.py`
Creates (or reuses) a room with all 6 gate slots filled, roles assigned, and `table_status=SIG_SELECT`. Prints one pre-auth URL per gamer for pasting into 6 Firefox Multi-Account Container tabs.
```bash
python src/manage.py setup_sig_session
python src/manage.py setup_sig_session --base-url http://localhost:8000
python src/manage.py setup_sig_session --room <uuid> # reuse existing room
```
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all created as superusers with Earthman deck equipped. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
FTs are isolated by **directory path** (`src/functional_tests/`), not by tag.
Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows compressor PermissionError by deleting stale CACHE at startup + monkey-patching retry logic.
## CI/CD
- Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`)
- Gitea: `https://gitea.earthmanrpg.me` | Woodpecker CI: `https://ci.earthmanrpg.me`
- Push to `main` triggers Woodpecker → deploys to staging
## SCSS Import Order
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → billboard → game-kit → wallet-tokens`
## Critical Gotchas
### TransactionTestCase flushes migration data
FTs use `TransactionTestCase` which flushes migration-seeded rows. Any IT/FT `setUp` that needs `Applet` rows must call `Applet.objects.get_or_create(slug=..., defaults={...})` — never rely on migration data surviving.
### Static files in tests
`StaticLiveServerTestCase` serves via `STATICFILES_FINDERS` only — NOT from `STATIC_ROOT`. JS/CSS that lives only in `src/static/` (STATIC_ROOT, gitignored) is a silent 404 in CI. All app JS must live in `src/apps/<app>/static/` or `src/static_src/`.
### msgpack integer key bug (Django Channels)
`channels_redis` uses msgpack with `strict_map_key=True` — integer dict keys in `group_send` payloads raise `ValueError` and crash consumers. Always use `str(slot_number)` as dict keys.
### Multi-browser FTs in CI
Any FT opening a second browser must pass `FirefoxOptions` with `--headless` when `HEADLESS` env var is set. Bare `webdriver.Firefox()` crashes in headless CI with `Process unexpectedly closed with status 1`.
### Selenium + CSS text-transform
Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase` causes `assertIn("Test Room", body.text)` to fail. Assert against the uppercased string or use `body.text.upper()`.
### Tooltip portal pattern
`mask-image` on grid containers clips `position: absolute` tooltips. Use `#id_tooltip_portal` (`position: fixed; z-index: 9999`) at page root. See `gameboard.js` + `wallet.js`.
### Applet menus + container-type
`container-type: inline-size` creates a containing block for all positioned descendants — applet menus must live OUTSIDE `#id_applets_container` to be viewport-fixed.
### ABU session auth
`create_pre_authenticated_session` must set `HASH_SESSION_KEY = user.get_session_auth_hash()` and hardcode `BACKEND_SESSION_KEY` to the passwordless backend string.
### Magic login email mock paths
- View tests: `apps.lyric.views.send_login_email_task.delay`
- Task unit tests: `apps.lyric.tasks.requests.post`
- FTs: mock both with `side_effect=send_login_email_task`
## Teaching Style
User prefers guided learning: describe what to type and why, let them write the code, then review together. But for now, given user's accelerated timetable, please respond with complete code snippets for user to transcribe directly.

View File

@@ -20,4 +20,4 @@ RUN DJANGO_SECRET_KEY=build-dummy DJANGO_ALLOWED_HOST=localhost python manage.py
RUN adduser --uid 1234 nonroot RUN adduser --uid 1234 nonroot
USER nonroot USER nonroot
CMD ["gunicorn", "--bind", ":8888", "core.wsgi:application"] CMD ["gunicorn", "--bind", ":8888", "-k", "uvicorn.workers.UvicornWorker", "core.asgi:application"]

View File

@@ -129,6 +129,7 @@
image: gitea.earthmanrpg.me/discoman/gamearray:latest image: gitea.earthmanrpg.me/discoman/gamearray:latest
state: started state: started
recreate: true recreate: true
restart_policy: unless-stopped
env: env:
DJANGO_DEBUG_FALSE: "1" DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}" DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
@@ -150,6 +151,7 @@
image: gitea.earthmanrpg.me/discoman/gamearray:latest image: gitea.earthmanrpg.me/discoman/gamearray:latest
state: started state: started
recreate: true recreate: true
restart_policy: unless-stopped
env: env:
DJANGO_DEBUG_FALSE: "1" DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}" DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"

View File

@@ -12,6 +12,7 @@ docker rm gamearray 2>/dev/null || true
echo "==> Starting new container..." echo "==> Starting new container..."
docker run -d --name gamearray \ docker run -d --name gamearray \
--restart unless-stopped \
--env-file /opt/gamearray/gamearray.env \ --env-file /opt/gamearray/gamearray.env \
--network gamearray_net \ --network gamearray_net \
-p 127.0.0.1:8888:8888 \ -p 127.0.0.1:8888:8888 \
@@ -23,6 +24,7 @@ docker rm gamearray_celery 2>/dev/null || true
echo "==> Starting new celery worker..." echo "==> Starting new celery worker..."
docker run -d --name gamearray_celery \ docker run -d --name gamearray_celery \
--restart unless-stopped \
--env-file /opt/gamearray/gamearray.env \ --env-file /opt/gamearray/gamearray.env \
--network gamearray_net \ --network gamearray_net \
"$IMAGE" python -m celery -A core worker -l info "$IMAGE" python -m celery -A core worker -l info

View File

@@ -17,6 +17,9 @@ server {
location / { location / {
proxy_pass http://127.0.0.1:8888; proxy_pass http://127.0.0.1:8888;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

0
pyswiss/apps/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChartsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.charts'

130
pyswiss/apps/charts/calc.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Core ephemeris calculation logic — shared by views and management commands.
"""
from django.conf import settings as django_settings
import swisseph as swe
DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry
SIGNS = [
'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
]
SIGN_ELEMENT = {
'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire',
'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth',
'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air',
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water',
}
ASPECTS = [
('Conjunction', 0, 8.0),
('Sextile', 60, 6.0),
('Square', 90, 8.0),
('Trine', 120, 8.0),
('Opposition', 180, 10.0),
]
PLANET_CODES = {
'Sun': swe.SUN,
'Moon': swe.MOON,
'Mercury': swe.MERCURY,
'Venus': swe.VENUS,
'Mars': swe.MARS,
'Jupiter': swe.JUPITER,
'Saturn': swe.SATURN,
'Uranus': swe.URANUS,
'Neptune': swe.NEPTUNE,
'Pluto': swe.PLUTO,
}
def set_ephe_path():
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
if ephe_path:
swe.set_ephe_path(ephe_path)
def get_sign(lon):
return SIGNS[int(lon // 30) % 12]
def get_julian_day(dt):
return swe.julday(
dt.year, dt.month, dt.day,
dt.hour + dt.minute / 60 + dt.second / 3600,
)
def get_planet_positions(jd):
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
planets = {}
for name, code in PLANET_CODES.items():
pos, _ = swe.calc_ut(jd, code, flag)
degree = pos[0]
planets[name] = {
'sign': get_sign(degree),
'degree': degree,
'retrograde': pos[3] < 0,
}
return planets
def get_element_counts(planets):
sign_counts = {s: 0 for s in SIGNS}
counts = {'Fire': 0, 'Water': 0, 'Earth': 0, 'Air': 0}
for data in planets.values():
sign = data['sign']
counts[SIGN_ELEMENT[sign]] += 1
sign_counts[sign] += 1
# Time: highest planet concentration in a single sign, minus 1
counts['Time'] = max(sign_counts.values()) - 1
# Space: longest consecutive run of occupied signs (circular), minus 1
indices = [i for i, s in enumerate(SIGNS) if sign_counts[s] > 0]
max_seq = 0
for start in range(len(indices)):
seq_len = 1
for offset in range(1, len(indices)):
if (indices[start] + offset) % len(SIGNS) in indices:
seq_len += 1
else:
break
max_seq = max(max_seq, seq_len)
counts['Space'] = max_seq - 1
return counts
def calculate_aspects(planets):
"""Return a list of aspects between all planet pairs.
Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}.
Only the first matching aspect type is reported per pair (aspects are
well-separated enough that at most one can apply with standard orbs).
"""
names = list(planets.keys())
aspects = []
for i, name1 in enumerate(names):
for name2 in names[i + 1:]:
deg1 = planets[name1]['degree']
deg2 = planets[name2]['degree']
angle = abs(deg1 - deg2)
if angle > 180:
angle = 360 - angle
for aspect_name, target, max_orb in ASPECTS:
orb = abs(angle - target)
if orb <= max_orb:
aspects.append({
'planet1': name1,
'planet2': name2,
'type': aspect_name,
'angle': round(angle, 2),
'orb': round(orb, 2),
})
break
return aspects

View File

@@ -0,0 +1,49 @@
from datetime import date, datetime, timedelta, timezone
from django.core.management.base import BaseCommand
from apps.charts.calc import get_element_counts, get_julian_day, get_planet_positions, set_ephe_path
from apps.charts.models import EphemerisSnapshot
class Command(BaseCommand):
help = 'Pre-compute ephemeris snapshots for a date range (one per day at noon UTC).'
def add_arguments(self, parser):
parser.add_argument('--date-from', required=True, help='Start date (YYYY-MM-DD)')
parser.add_argument('--date-to', required=True, help='End date (YYYY-MM-DD, inclusive)')
def handle(self, *args, **options):
set_ephe_path()
date_from = date.fromisoformat(options['date_from'])
date_to = date.fromisoformat(options['date_to'])
current = date_from
count = 0
while current <= date_to:
dt = datetime(current.year, current.month, current.day,
12, 0, 0, tzinfo=timezone.utc)
jd = get_julian_day(dt)
planets = get_planet_positions(jd)
elements = get_element_counts(planets)
EphemerisSnapshot.objects.update_or_create(
dt=dt,
defaults={
'fire': elements['Fire'],
'water': elements['Water'],
'earth': elements['Earth'],
'air': elements['Air'],
'time_el': elements['Time'],
'space_el': elements['Space'],
'chart_data': {'planets': planets},
},
)
current += timedelta(days=1)
count += 1
if options['verbosity'] > 0:
self.stdout.write(
self.style.SUCCESS(f'Created/updated {count} snapshot(s).')
)

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0.4 on 2026-04-13 20:40
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='EphemerisSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('dt', models.DateTimeField(db_index=True, unique=True)),
('fire', models.PositiveSmallIntegerField()),
('water', models.PositiveSmallIntegerField()),
('earth', models.PositiveSmallIntegerField()),
('air', models.PositiveSmallIntegerField()),
('time_el', models.PositiveSmallIntegerField()),
('space_el', models.PositiveSmallIntegerField()),
('chart_data', models.JSONField()),
],
options={
'ordering': ['dt'],
},
),
]

View File

@@ -0,0 +1,36 @@
from django.db import models
class EphemerisSnapshot(models.Model):
"""Pre-computed chart data for a single point in time.
Element counts are stored as denormalised columns for fast DB-level range
filtering. Full planet/house data lives in chart_data (JSONField) for
response serialisation.
"""
dt = models.DateTimeField(unique=True, db_index=True)
# Denormalised element counts — indexed for range queries
fire = models.PositiveSmallIntegerField()
water = models.PositiveSmallIntegerField()
earth = models.PositiveSmallIntegerField()
air = models.PositiveSmallIntegerField()
time_el = models.PositiveSmallIntegerField()
space_el = models.PositiveSmallIntegerField()
# Full chart payload
chart_data = models.JSONField()
class Meta:
ordering = ['dt']
def elements_dict(self):
return {
'Fire': self.fire,
'Water': self.water,
'Earth': self.earth,
'Air': self.air,
'Time': self.time_el,
'Space': self.space_el,
}

View File

View File

@@ -0,0 +1,159 @@
"""
Integration tests for GET /api/charts/ — ephemeris range/filter queries.
These tests drive the EphemerisSnapshot model and list view.
Snapshots are created directly in setUp — no live ephemeris calc needed.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from django.test import TestCase
from apps.charts.models import EphemerisSnapshot
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
CHART_DATA_STUB = {
'planets': {
'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False},
'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False},
'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False},
'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False},
'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False},
'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False},
'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False},
'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False},
'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False},
'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False},
},
'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0},
}
def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3,
chart_data=None):
return EphemerisSnapshot.objects.create(
dt=dt_str,
fire=fire, water=water, earth=earth, air=air,
time_el=time_el, space_el=space_el,
chart_data=chart_data or CHART_DATA_STUB,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class ChartsListApiTest(TestCase):
"""GET /api/charts/ — query pre-computed ephemeris snapshots."""
def setUp(self):
make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2)
make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2)
make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2)
# Outside the usual date range — should not appear in filtered results
make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2)
def _get(self, params=None):
return self.client.get('/api/charts/', params or {})
# ── guards ────────────────────────────────────────────────────────────
def test_charts_returns_400_if_date_from_missing(self):
response = self._get({'date_to': '2000-01-31'})
self.assertEqual(response.status_code, 400)
def test_charts_returns_400_if_date_to_missing(self):
response = self._get({'date_from': '2000-01-01'})
self.assertEqual(response.status_code, 400)
def test_charts_returns_400_for_invalid_date_from(self):
response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'})
self.assertEqual(response.status_code, 400)
def test_charts_returns_400_if_date_to_before_date_from(self):
response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'})
self.assertEqual(response.status_code, 400)
# ── response shape ────────────────────────────────────────────────────
def test_charts_returns_200_for_valid_params(self):
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
self.assertEqual(response.status_code, 200)
def test_charts_response_is_json(self):
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
self.assertIn('application/json', response['Content-Type'])
def test_charts_response_has_results_and_count(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
self.assertIn('results', data)
self.assertIn('count', data)
def test_each_result_has_dt_and_elements(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
for result in data['results']:
with self.subTest(dt=result.get('dt')):
self.assertIn('dt', result)
self.assertIn('elements', result)
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
self.assertIn(key, result['elements'])
def test_each_result_has_planets(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
for result in data['results']:
with self.subTest(dt=result.get('dt')):
self.assertIn('planets', result)
# ── date range filtering ──────────────────────────────────────────────
def test_charts_returns_only_snapshots_in_date_range(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
self.assertEqual(data['count'], 3)
def test_charts_count_matches_results_length(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json()
self.assertEqual(data['count'], len(data['results']))
def test_charts_date_range_is_inclusive(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json()
self.assertEqual(data['count'], 1)
def test_charts_results_ordered_by_dt(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
dts = [r['dt'] for r in data['results']]
self.assertEqual(dts, sorted(dts))
# ── element range filtering ───────────────────────────────────────────
def test_charts_filters_by_fire_min(self):
# Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3,
}).json()
self.assertEqual(data['count'], 1)
def test_charts_filters_by_water_min(self):
# Only the Jan 2 snapshot has water=4
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4,
}).json()
self.assertEqual(data['count'], 1)
def test_charts_filters_by_earth_min(self):
# Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4,
}).json()
self.assertEqual(data['count'], 1)
def test_charts_multiple_element_filters_are_conjunctive(self):
# fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1)
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31',
'fire_min': 2, 'water_min': 2,
}).json()
self.assertEqual(data['count'], 2)

View File

@@ -0,0 +1,215 @@
"""
Integration tests for the PySwiss chart calculation API.
These tests drive the TDD implementation of GET /api/chart/ and GET /api/tz/.
They verify the HTTP contract using Django's test client.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from django.test import TestCase
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22')
J2000 = '2000-01-01T12:00:00Z'
LONDON = {'lat': 51.5074, 'lon': -0.1278}
# Well-known coordinates with unambiguous timezone results
NEW_YORK = {'lat': 40.7128, 'lon': -74.0060} # America/New_York
TOKYO = {'lat': 35.6762, 'lon': 139.6503} # Asia/Tokyo
REYKJAVIK = {'lat': 64.1355, 'lon': -21.8954} # Atlantic/Reykjavik
class ChartApiTest(TestCase):
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates."""
def _get(self, params):
return self.client.get('/api/chart/', params)
# ── guards ────────────────────────────────────────────────────────────
def test_chart_returns_400_if_dt_missing(self):
response = self._get({'lat': 51.5074, 'lon': -0.1278})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_if_lat_missing(self):
response = self._get({'dt': J2000, 'lon': -0.1278})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_if_lon_missing(self):
response = self._get({'dt': J2000, 'lat': 51.5074})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_for_invalid_dt_format(self):
response = self._get({'dt': 'not-a-date', **LONDON})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_for_out_of_range_lat(self):
response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278})
self.assertEqual(response.status_code, 400)
# ── response shape ────────────────────────────────────────────────────
def test_chart_returns_200_for_valid_params(self):
response = self._get({'dt': J2000, **LONDON})
self.assertEqual(response.status_code, 200)
def test_chart_response_is_json(self):
response = self._get({'dt': J2000, **LONDON})
self.assertIn('application/json', response['Content-Type'])
def test_chart_returns_all_ten_planets(self):
data = self._get({'dt': J2000, **LONDON}).json()
expected = {
'Sun', 'Moon', 'Mercury', 'Venus', 'Mars',
'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto',
}
self.assertEqual(set(data['planets'].keys()), expected)
def test_each_planet_has_sign_degree_and_retrograde(self):
data = self._get({'dt': J2000, **LONDON}).json()
for name, planet in data['planets'].items():
with self.subTest(planet=name):
self.assertIn('sign', planet)
self.assertIn('degree', planet)
self.assertIn('retrograde', planet)
def test_chart_returns_houses(self):
data = self._get({'dt': J2000, **LONDON}).json()
houses = data['houses']
self.assertEqual(len(houses['cusps']), 12)
self.assertIn('asc', houses)
self.assertIn('mc', houses)
def test_chart_returns_six_element_counts(self):
"""Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent."""
data = self._get({'dt': J2000, **LONDON}).json()
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
with self.subTest(element=key):
self.assertIn(key, data['elements'])
def test_chart_reports_active_house_system(self):
data = self._get({'dt': J2000, **LONDON}).json()
self.assertIn('house_system', data)
# ── calculation correctness ───────────────────────────────────────────
def test_sun_is_in_capricorn_at_j2000(self):
"""Regression: Sun at J2000.0 is ~280.37° — Capricorn."""
data = self._get({'dt': J2000, **LONDON}).json()
sun = data['planets']['Sun']
self.assertEqual(sun['sign'], 'Capricorn')
self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1)
def test_sun_is_not_retrograde(self):
"""The Sun never goes retrograde."""
data = self._get({'dt': J2000, **LONDON}).json()
self.assertFalse(data['planets']['Sun']['retrograde'])
def test_element_counts_sum_to_ten(self):
"""All 10 planets are assigned to exactly one classical element."""
data = self._get({'dt': J2000, **LONDON}).json()
classical = sum(
data['elements'][e] for e in ('Fire', 'Water', 'Earth', 'Air')
)
self.assertEqual(classical, 10)
# ── house system ──────────────────────────────────────────────────────
def test_default_house_system_is_porphyry(self):
"""Porphyry ('O') is the project default — no param needed."""
data = self._get({'dt': J2000, **LONDON}).json()
self.assertEqual(data['house_system'], 'O')
def test_non_superuser_cannot_override_house_system(self):
"""House system override is superuser-only; plain requests get 403."""
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
self.assertEqual(response.status_code, 403)
# ── aspects ───────────────────────────────────────────────────────────
def test_chart_returns_aspects_list(self):
data = self._get({'dt': J2000, **LONDON}).json()
self.assertIn('aspects', data)
self.assertIsInstance(data['aspects'], list)
def test_each_aspect_has_required_fields(self):
data = self._get({'dt': J2000, **LONDON}).json()
for aspect in data['aspects']:
with self.subTest(aspect=aspect):
self.assertIn('planet1', aspect)
self.assertIn('planet2', aspect)
self.assertIn('type', aspect)
self.assertIn('angle', aspect)
self.assertIn('orb', aspect)
def test_sun_saturn_trine_present_at_j2000(self):
"""Sun ~280.37° (Capricorn) and Saturn ~40.73° (Taurus) are ~120.36° apart — Trine."""
data = self._get({'dt': J2000, **LONDON}).json()
pairs = {(a['planet1'], a['planet2'], a['type']) for a in data['aspects']}
self.assertIn(('Sun', 'Saturn', 'Trine'), pairs)
class TimezoneApiTest(TestCase):
"""GET /api/tz/ — resolve IANA timezone from lat/lon coordinates."""
def _get(self, params):
return self.client.get('/api/tz/', params)
# ── guards ────────────────────────────────────────────────────────────
def test_returns_400_if_lat_missing(self):
response = self._get({'lon': -74.0060})
self.assertEqual(response.status_code, 400)
def test_returns_400_if_lon_missing(self):
response = self._get({'lat': 40.7128})
self.assertEqual(response.status_code, 400)
def test_returns_400_for_invalid_lat(self):
response = self._get({'lat': 'abc', 'lon': -74.0060})
self.assertEqual(response.status_code, 400)
def test_returns_400_for_out_of_range_lat(self):
response = self._get({'lat': 999, 'lon': -74.0060})
self.assertEqual(response.status_code, 400)
def test_returns_400_for_out_of_range_lon(self):
response = self._get({'lat': 40.7128, 'lon': 999})
self.assertEqual(response.status_code, 400)
# ── response shape ────────────────────────────────────────────────────
def test_returns_200_for_valid_coords(self):
response = self._get(NEW_YORK)
self.assertEqual(response.status_code, 200)
def test_response_is_json(self):
response = self._get(NEW_YORK)
self.assertIn('application/json', response['Content-Type'])
def test_response_contains_timezone_key(self):
data = self._get(NEW_YORK).json()
self.assertIn('timezone', data)
def test_timezone_is_a_string(self):
data = self._get(NEW_YORK).json()
self.assertIsInstance(data['timezone'], str)
# ── correctness ───────────────────────────────────────────────────────
def test_new_york_timezone(self):
data = self._get(NEW_YORK).json()
self.assertEqual(data['timezone'], 'America/New_York')
def test_tokyo_timezone(self):
data = self._get(TOKYO).json()
self.assertEqual(data['timezone'], 'Asia/Tokyo')
def test_reykjavik_timezone(self):
data = self._get(REYKJAVIK).json()
self.assertEqual(data['timezone'], 'Atlantic/Reykjavik')

View File

@@ -0,0 +1,148 @@
"""
Unit tests for calc.py helper functions.
These tests verify pure calculation logic without hitting the database
or the Swiss Ephemeris — all inputs are fixed synthetic data.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from django.test import SimpleTestCase
from apps.charts.calc import calculate_aspects
# ---------------------------------------------------------------------------
# Synthetic planet data — degrees chosen for predictable aspects
# Matches FAKE_PLANETS in test_populate_ephemeris.py
# ---------------------------------------------------------------------------
FAKE_PLANETS = {
'Sun': {'degree': 10.0}, # Aries
'Moon': {'degree': 130.0}, # Leo — 120° from Sun → Trine
'Mercury': {'degree': 250.0}, # Sagittarius — 120° from Sun → Trine
'Venus': {'degree': 40.0}, # Taurus — 90° from Moon → Square
'Mars': {'degree': 160.0}, # Virgo — 60° from Neptune → Sextile
'Jupiter': {'degree': 280.0}, # Capricorn — 120° from Mars → Trine
'Saturn': {'degree': 70.0}, # Gemini — 120° from Uranus → Trine
'Uranus': {'degree': 310.0}, # Aquarius — 60° from Sun (wrap) → Sextile
'Neptune': {'degree': 100.0}, # Cancer
'Pluto': {'degree': 340.0}, # Pisces
}
def _aspect_pairs(aspects):
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
class CalculateAspectsTest(SimpleTestCase):
def setUp(self):
self.aspects = calculate_aspects(FAKE_PLANETS)
# ── return shape ──────────────────────────────────────────────────────
def test_returns_a_list(self):
self.assertIsInstance(self.aspects, list)
def test_each_aspect_has_required_keys(self):
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIn('planet1', aspect)
self.assertIn('planet2', aspect)
self.assertIn('type', aspect)
self.assertIn('angle', aspect)
self.assertIn('orb', aspect)
def test_each_aspect_type_is_a_known_name(self):
known = {'Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition'}
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIn(aspect['type'], known)
def test_angle_and_orb_are_floats(self):
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIsInstance(aspect['angle'], float)
self.assertIsInstance(aspect['orb'], float)
def test_no_self_aspects(self):
for aspect in self.aspects:
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
def test_no_duplicate_pairs(self):
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
self.assertEqual(len(pairs), len(set(pairs)))
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
def test_sun_moon_trine(self):
"""Moon at 130° is exactly 120° from Sun at 10°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
def test_sun_mercury_trine(self):
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
def test_moon_mercury_trine(self):
"""Moon 130° → Mercury 250° = 120°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
def test_moon_venus_square(self):
"""Moon 130° → Venus 40° = 90°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
def test_venus_neptune_sextile(self):
"""Venus 40° → Neptune 100° = 60°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
def test_mars_neptune_sextile(self):
"""Mars 160° → Neptune 100° = 60°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
def test_sun_uranus_sextile(self):
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
def test_mars_jupiter_trine(self):
"""Mars 160° → Jupiter 280° = 120°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
def test_saturn_uranus_trine(self):
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
# ── orb bounds ────────────────────────────────────────────────────────
def test_orb_is_within_allowed_maximum(self):
max_orbs = {
'Conjunction': 8.0,
'Sextile': 6.0,
'Square': 8.0,
'Trine': 8.0,
'Opposition': 10.0,
}
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertLessEqual(
aspect['orb'], max_orbs[aspect['type']],
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
)
def test_exact_trine_has_zero_orb(self):
"""Sun-Moon at exactly 120° should report orb of 0.0."""
sun_moon = next(
a for a in self.aspects
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
)
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)

View File

@@ -0,0 +1,99 @@
"""
Unit tests for the populate_ephemeris management command.
pyswisseph calls are mocked — these tests verify date iteration,
snapshot persistence, and idempotency without touching the ephemeris.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from datetime import datetime, timezone
from unittest.mock import patch
from django.core.management import call_command
from django.test import TestCase
from apps.charts.models import EphemerisSnapshot
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# 10 planets covering Fire×3, Earth×3, Air×2, Water×2 (one per sign)
# Expected: fire=3, water=2, earth=3, air=2, time=0, space=9
FAKE_PLANETS = {
'Sun': {'sign': 'Aries', 'degree': 10.0, 'retrograde': False},
'Moon': {'sign': 'Leo', 'degree': 130.0, 'retrograde': False},
'Mercury': {'sign': 'Sagittarius', 'degree': 250.0, 'retrograde': False},
'Venus': {'sign': 'Taurus', 'degree': 40.0, 'retrograde': False},
'Mars': {'sign': 'Virgo', 'degree': 160.0, 'retrograde': False},
'Jupiter': {'sign': 'Capricorn', 'degree': 280.0, 'retrograde': False},
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'retrograde': False},
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'retrograde': False},
'Neptune': {'sign': 'Cancer', 'degree': 100.0, 'retrograde': False},
'Pluto': {'sign': 'Pisces', 'degree': 340.0, 'retrograde': False},
}
PATCH_TARGET = (
'apps.charts.management.commands.populate_ephemeris.get_planet_positions'
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class PopulateEphemerisCommandTest(TestCase):
def _run(self, date_from, date_to):
with patch(PATCH_TARGET, return_value=FAKE_PLANETS):
call_command('populate_ephemeris',
date_from=date_from, date_to=date_to,
verbosity=0)
# ── date iteration ────────────────────────────────────────────────────
def test_creates_one_snapshot_per_day(self):
self._run('2000-01-01', '2000-01-03')
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
def test_single_day_range_creates_one_snapshot(self):
self._run('2000-01-01', '2000-01-01')
self.assertEqual(EphemerisSnapshot.objects.count(), 1)
def test_snapshots_are_at_noon_utc(self):
self._run('2000-01-01', '2000-01-01')
snap = EphemerisSnapshot.objects.get()
self.assertEqual(snap.dt, datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc))
# ── idempotency ───────────────────────────────────────────────────────
def test_rerunning_does_not_create_duplicates(self):
self._run('2000-01-01', '2000-01-03')
self._run('2000-01-01', '2000-01-03')
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
def test_overlapping_ranges_do_not_duplicate(self):
self._run('2000-01-01', '2000-01-03')
self._run('2000-01-02', '2000-01-05')
self.assertEqual(EphemerisSnapshot.objects.count(), 5)
# ── element counts ────────────────────────────────────────────────────
def test_element_counts_are_persisted(self):
self._run('2000-01-01', '2000-01-01')
snap = EphemerisSnapshot.objects.get()
self.assertEqual(snap.fire, 3)
self.assertEqual(snap.water, 2)
self.assertEqual(snap.earth, 3)
self.assertEqual(snap.air, 2)
self.assertEqual(snap.time_el, 0)
self.assertEqual(snap.space_el, 9)
# ── chart_data payload ────────────────────────────────────────────────
def test_chart_data_contains_planets(self):
self._run('2000-01-01', '2000-01-01')
snap = EphemerisSnapshot.objects.get()
self.assertEqual(snap.chart_data['planets'], FAKE_PLANETS)

View File

@@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path('chart/', views.chart, name='chart'),
path('charts/', views.charts_list, name='charts_list'),
path('tz/', views.timezone_lookup, name='timezone_lookup'),
]

View File

@@ -0,0 +1,143 @@
from datetime import datetime, timezone
from django.http import HttpResponse, JsonResponse
from timezonefinder import TimezoneFinder
import swisseph as swe
from .calc import (
DEFAULT_HOUSE_SYSTEM,
calculate_aspects,
get_element_counts,
get_julian_day,
get_planet_positions,
set_ephe_path,
)
from .models import EphemerisSnapshot
def chart(request):
dt_str = request.GET.get('dt')
lat_str = request.GET.get('lat')
lon_str = request.GET.get('lon')
if not dt_str or lat_str is None or lon_str is None:
return HttpResponse(status=400)
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
except ValueError:
return HttpResponse(status=400)
try:
lat = float(lat_str)
lon = float(lon_str)
except ValueError:
return HttpResponse(status=400)
if not (-90 <= lat <= 90):
return HttpResponse(status=400)
house_system_param = request.GET.get('house_system')
if house_system_param is not None:
if not (hasattr(request, 'user') and request.user.is_authenticated
and request.user.is_superuser):
return HttpResponse(status=403)
house_system = house_system_param
else:
house_system = DEFAULT_HOUSE_SYSTEM
set_ephe_path()
jd = get_julian_day(dt)
planets = get_planet_positions(jd)
cusps, ascmc = swe.houses(jd, lat, lon, house_system.encode())
houses = {
'cusps': list(cusps),
'asc': ascmc[0],
'mc': ascmc[1],
}
return JsonResponse({
'planets': planets,
'houses': houses,
'elements': get_element_counts(planets),
'aspects': calculate_aspects(planets),
'house_system': house_system,
})
_tf = TimezoneFinder()
def timezone_lookup(request):
"""GET /api/tz/ — resolve IANA timezone string from lat/lon.
Query params: lat (float), lon (float)
Returns: { "timezone": "America/New_York" }
Returns 404 JSON { "timezone": null } if coordinates fall in international
waters (no timezone found) — not an error, just no result.
"""
lat_str = request.GET.get('lat')
lon_str = request.GET.get('lon')
if lat_str is None or lon_str is None:
return HttpResponse(status=400)
try:
lat = float(lat_str)
lon = float(lon_str)
except ValueError:
return HttpResponse(status=400)
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return HttpResponse(status=400)
tz = _tf.timezone_at(lat=lat, lng=lon)
return JsonResponse({'timezone': tz})
def charts_list(request):
date_from_str = request.GET.get('date_from')
date_to_str = request.GET.get('date_to')
if not date_from_str or not date_to_str:
return HttpResponse(status=400)
try:
date_from = datetime.strptime(date_from_str, '%Y-%m-%d').replace(
tzinfo=timezone.utc)
date_to = datetime.strptime(date_to_str, '%Y-%m-%d').replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc)
except ValueError:
return HttpResponse(status=400)
if date_to < date_from:
return HttpResponse(status=400)
qs = EphemerisSnapshot.objects.filter(dt__gte=date_from, dt__lte=date_to)
element_fields = {
'fire_min': 'fire', 'water_min': 'water',
'earth_min': 'earth', 'air_min': 'air',
'time_min': 'time_el', 'space_min': 'space_el',
}
for param, field in element_fields.items():
value = request.GET.get(param)
if value is not None:
try:
qs = qs.filter(**{f'{field}__gte': int(value)})
except ValueError:
return HttpResponse(status=400)
results = [
{
'dt': snap.dt.isoformat(),
'elements': snap.elements_dict(),
'planets': snap.chart_data.get('planets', {}),
}
for snap in qs
]
return JsonResponse({'results': results, 'count': len(results)})

0
pyswiss/core/__init__.py Normal file
View File

49
pyswiss/core/settings.py Normal file
View File

@@ -0,0 +1,49 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('SECRET_KEY', 'pyswiss-dev-only-key-replace-in-production')
DEBUG = os.environ.get('DEBUG', 'true').lower() != 'false'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
INSTALLED_APPS = [
'corsheaders',
'django.contrib.contenttypes',
'django.contrib.auth',
'apps.charts',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
]
CORS_ALLOWED_ORIGIN_REGEXES = [
r'^https://.*\.earthmanrpg\.me$',
r'^http://localhost(:\d+)?$',
]
ROOT_URLCONF = 'core.urls'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
USE_TZ = True
TIME_ZONE = 'UTC'
# Swiss Ephemeris data files.
# Override via SWISSEPH_PATH env var on staging/production.
SWISSEPH_PATH = os.environ.get(
'SWISSEPH_PATH',
r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe',
)

5
pyswiss/core/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path, include
urlpatterns = [
path('api/', include('apps.charts.urls')),
]

6
pyswiss/core/wsgi.py Normal file
View File

@@ -0,0 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

20
pyswiss/manage.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and available "
"on your PYTHONPATH environment variable? Did you forget to activate "
"a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

5
pyswiss/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
django==6.0.4
django-cors-headers==4.3.1
gunicorn==23.0.0
pyswisseph==2.10.3.2
timezonefinder==8.2.2

View File

@@ -6,7 +6,9 @@ channels
channels-redis channels-redis
charset-normalizer==3.4.4 charset-normalizer==3.4.4
coverage coverage
cryptography
cssselect==1.3.0 cssselect==1.3.0
daphne
dj-database-url dj-database-url
Django==6.0 Django==6.0
django-compressor django-compressor

View File

@@ -1,7 +1,9 @@
celery celery
cryptography
channels channels
channels-redis channels-redis
cssselect==1.3.0 cssselect==1.3.0
daphne
Django==6.0 Django==6.0
dj-database-url dj-database-url
django-compressor django-compressor

0
src/apps/ap/__init__.py Normal file
View File

7
src/apps/ap/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ApConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.ap"
label = "ap"

View File

View File

View File

@@ -0,0 +1,119 @@
import json
from django.test import TestCase
from apps.drama.models import GameEvent, record
from apps.epic.models import Room
from apps.lyric.models import User
class WebFingerTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="actor")
def test_returns_jrd_for_known_user(self):
response = self.client.get(
"/.well-known/webfinger",
{"resource": "acct:actor@earthmanrpg.me"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/jrd+json")
def test_jrd_links_to_actor_url(self):
response = self.client.get(
"/.well-known/webfinger",
{"resource": "acct:actor@earthmanrpg.me"},
)
data = json.loads(response.content)
hrefs = [link["href"] for link in data["links"]]
self.assertTrue(any("/ap/users/actor/" in href for href in hrefs))
def test_returns_404_for_unknown_user(self):
response = self.client.get(
"/.well-known/webfinger",
{"resource": "acct:nobody@earthmanrpg.me"},
)
self.assertEqual(response.status_code, 404)
def test_returns_400_for_missing_resource(self):
response = self.client.get("/.well-known/webfinger")
self.assertEqual(response.status_code, 400)
class ActorViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="actor")
def test_returns_200_for_known_user(self):
response = self.client.get("/ap/users/actor/")
self.assertEqual(response.status_code, 200)
def test_returns_activity_json_content_type(self):
response = self.client.get("/ap/users/actor/")
self.assertEqual(response["Content-Type"], "application/activity+json")
def test_actor_has_required_fields(self):
response = self.client.get("/ap/users/actor/")
data = json.loads(response.content)
self.assertEqual(data["type"], "Person")
self.assertIn("id", data)
self.assertIn("outbox", data)
self.assertIn("publicKey", data)
def test_requires_no_authentication(self):
# AP Actor endpoints must be publicly accessible
self.client.logout()
response = self.client.get("/ap/users/actor/")
self.assertEqual(response.status_code, 200)
def test_returns_404_for_unknown_user(self):
response = self.client.get("/ap/users/nobody/")
self.assertEqual(response.status_code, 404)
class OutboxViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="actor")
self.room = Room.objects.create(name="Test Room", owner=self.user)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin", renewal_days=7,
)
record(
self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role="PC", slot_number=1, role_display="Player",
)
# INVITE_SENT is unsupported — should be excluded from outbox
record(self.room, GameEvent.INVITE_SENT, actor=self.user)
def test_returns_200(self):
response = self.client.get("/ap/users/actor/outbox/")
self.assertEqual(response.status_code, 200)
def test_returns_activity_json_content_type(self):
response = self.client.get("/ap/users/actor/outbox/")
self.assertEqual(response["Content-Type"], "application/activity+json")
def test_outbox_is_ordered_collection(self):
response = self.client.get("/ap/users/actor/outbox/")
data = json.loads(response.content)
self.assertEqual(data["type"], "OrderedCollection")
def test_total_items_excludes_unsupported_verbs(self):
response = self.client.get("/ap/users/actor/outbox/")
data = json.loads(response.content)
# 2 supported events (SLOT_FILLED + ROLE_SELECTED); INVITE_SENT excluded
self.assertEqual(data["totalItems"], 2)
def test_requires_no_authentication(self):
self.client.logout()
response = self.client.get("/ap/users/actor/outbox/")
self.assertEqual(response.status_code, 200)
def test_returns_404_for_unknown_user(self):
response = self.client.get("/ap/users/nobody/outbox/")
self.assertEqual(response.status_code, 404)

View File

View File

@@ -0,0 +1,88 @@
from django.test import TestCase
from apps.drama.models import GameEvent, record
from apps.epic.models import Room
from apps.lyric.models import User
BASE = "https://earthmanrpg.me"
class ToActivityTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="testactor")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def _record(self, verb, **data):
return record(self.room, verb, actor=self.user, **data)
def test_slot_filled_returns_join_gate_activity(self):
event = self._record(
GameEvent.SLOT_FILLED,
slot_number=1, token_type="coin",
token_display="Coin", renewal_days=7,
)
activity = event.to_activity(BASE)
self.assertIsNotNone(activity)
self.assertEqual(activity["type"], "earthman:JoinGate")
def test_role_selected_returns_select_role_activity(self):
event = self._record(
GameEvent.ROLE_SELECTED,
role="PC", slot_number=1, role_display="Player",
)
activity = event.to_activity(BASE)
self.assertIsNotNone(activity)
self.assertEqual(activity["type"], "earthman:SelectRole")
def test_room_created_returns_create_activity(self):
event = self._record(GameEvent.ROOM_CREATED)
activity = event.to_activity(BASE)
self.assertIsNotNone(activity)
self.assertEqual(activity["type"], "Create")
def test_unsupported_verb_returns_none(self):
event = self._record(GameEvent.INVITE_SENT)
self.assertIsNone(event.to_activity(BASE))
def test_activity_contains_actor_url(self):
event = self._record(
GameEvent.ROLE_SELECTED,
role="PC", slot_number=1, role_display="Player",
)
activity = event.to_activity(BASE)
self.assertIn(BASE, activity["actor"])
def test_activity_contains_object_url(self):
event = self._record(
GameEvent.SLOT_FILLED,
slot_number=1, token_type="coin",
token_display="Coin", renewal_days=7,
)
activity = event.to_activity(BASE)
self.assertIn(str(self.room.id), activity["object"])
class EnsureKeypairTest(TestCase):
def test_ensure_keypair_populates_both_fields(self):
user = User.objects.create(email="keys@test.io")
self.assertEqual(user.ap_public_key, "")
self.assertEqual(user.ap_private_key, "")
user.ensure_keypair()
self.assertTrue(user.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
self.assertTrue(user.ap_private_key.startswith("-----BEGIN PRIVATE KEY-----"))
def test_ensure_keypair_persists_to_db(self):
user = User.objects.create(email="persist@test.io")
user.ensure_keypair()
refreshed = User.objects.get(pk=user.pk)
self.assertTrue(refreshed.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
def test_ensure_keypair_is_idempotent(self):
user = User.objects.create(email="idem@test.io")
user.ensure_keypair()
original_pub = user.ap_public_key
user.ensure_keypair()
self.assertEqual(user.ap_public_key, original_pub)

10
src/apps/ap/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "ap"
urlpatterns = [
path("users/<str:username>/", views.actor, name="actor"),
path("users/<str:username>/outbox/", views.outbox, name="outbox"),
]

83
src/apps/ap/views.py Normal file
View File

@@ -0,0 +1,83 @@
import json
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from apps.lyric.models import User
AP_CONTEXT = [
"https://www.w3.org/ns/activitystreams",
{"earthman": "https://earthmanrpg.me/ns#"},
]
def _base_url(request):
return f"{request.scheme}://{request.get_host()}"
def _ap_response(data):
return HttpResponse(
json.dumps(data),
content_type="application/activity+json",
)
def webfinger(request):
resource = request.GET.get("resource", "")
if not resource:
return HttpResponse(status=400)
# Expect acct:username@host
if not resource.startswith("acct:"):
return HttpResponse(status=400)
username = resource[len("acct:"):].split("@")[0]
user = get_object_or_404(User, username=username)
base = _base_url(request)
data = {
"subject": resource,
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": f"{base}/ap/users/{user.username}/",
}
],
}
return HttpResponse(json.dumps(data), content_type="application/jrd+json")
def actor(request, username):
user = get_object_or_404(User, username=username)
user.ensure_keypair()
base = _base_url(request)
actor_url = f"{base}/ap/users/{username}/"
data = {
"@context": AP_CONTEXT,
"id": actor_url,
"type": "Person",
"preferredUsername": username,
"inbox": f"{actor_url}inbox/",
"outbox": f"{actor_url}outbox/",
"publicKey": {
"id": f"{actor_url}#main-key",
"owner": actor_url,
"publicKeyPem": user.ap_public_key,
},
}
return _ap_response(data)
def outbox(request, username):
user = get_object_or_404(User, username=username)
base = _base_url(request)
events = user.game_events.select_related("room").order_by("timestamp")
activities = [a for e in events if (a := e.to_activity(base)) is not None]
actor_url = f"{base}/ap/users/{username}/"
data = {
"@context": AP_CONTEXT,
"id": f"{actor_url}outbox/",
"type": "OrderedCollection",
"totalItems": len(activities),
"orderedItems": activities,
}
return _ap_response(data)

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def increase_gameboard_applet_heights(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=3)
def revert_gameboard_applet_heights(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=2)
class Migration(migrations.Migration):
dependencies = [
('applets', '0004_rename_list_applet_slugs')
]
operations = [
migrations.RunPython(
increase_gameboard_applet_heights,
revert_gameboard_applet_heights,
)
]

View File

@@ -0,0 +1,48 @@
from django.db import migrations, models
def seed_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
def remove_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.filter(slug__in=[
"billboard-my-scrolls",
"billboard-my-contacts",
"billboard-most-recent",
]).delete()
class Migration(migrations.Migration):
dependencies = [
("applets", "0005_gameboard_applet_heights"),
]
operations = [
migrations.AlterField(
model_name="applet",
name="context",
field=models.CharField(
choices=[
("dashboard", "Dashboard"),
("gameboard", "Gameboard"),
("wallet", "Wallet"),
("billboard", "Billboard"),
],
default="dashboard",
max_length=20,
),
),
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
]

View File

@@ -0,0 +1,29 @@
from django.db import migrations
def fix_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
# billboard-scroll belongs only to the billscroll page template, not the grid
Applet.objects.filter(slug="billboard-scroll").delete()
# Rename "My Contacts" → "Contacts"
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
def reverse_fix_billboard_applets(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.get_or_create(
slug="billboard-scroll",
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
)
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
class Migration(migrations.Migration):
dependencies = [
("applets", "0006_billboard_applets"),
]
operations = [
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
]

View File

@@ -0,0 +1,25 @@
from django.db import migrations
def seed_game_kit_applets(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
for slug, name in [
('gk-trinkets', 'Trinkets'),
('gk-tokens', 'Tokens'),
('gk-decks', 'Card Decks'),
('gk-dice', 'Dice Sets'),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={'name': name, 'grid_cols': 3, 'grid_rows': 3, 'context': 'game-kit'},
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0007_fix_billboard_applets'),
]
operations = [
migrations.RunPython(seed_game_kit_applets, migrations.RunPython.noop)
]

View File

@@ -4,10 +4,12 @@ class Applet(models.Model):
DASHBOARD = "dashboard" DASHBOARD = "dashboard"
GAMEBOARD = "gameboard" GAMEBOARD = "gameboard"
WALLET = "wallet" WALLET = "wallet"
BILLBOARD = "billboard"
CONTEXT_CHOICES = [ CONTEXT_CHOICES = [
(DASHBOARD, "Dashboard"), (DASHBOARD, "Dashboard"),
(GAMEBOARD, "Gameboard"), (GAMEBOARD, "Gameboard"),
(WALLET, "Wallet"), (WALLET, "Wallet"),
(BILLBOARD, "Billboard"),
] ]
slug = models.SlugField(unique=True) slug = models.SlugField(unique=True)

View File

@@ -6,7 +6,9 @@ const initGearMenus = () => {
e.stopPropagation(); e.stopPropagation();
const menu = document.getElementById(menuId); const menu = document.getElementById(menuId);
if (!menu) return; if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none'; const opening = menu.style.display === 'none' || menu.style.display === '';
menu.style.display = opening ? 'block' : 'none';
gear.classList.toggle('active', opening);
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
@@ -14,6 +16,7 @@ const initGearMenus = () => {
if (!menu || menu.style.display === 'none') return; if (!menu || menu.style.display === 'none') return;
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) { if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
menu.style.display = 'none'; menu.style.display = 'none';
gear.classList.remove('active');
} }
}); });
}) })
@@ -25,6 +28,7 @@ document.addEventListener('DOMContentLoaded', initGearMenus);
const appletContainerIds = new Set([ const appletContainerIds = new Set([
'id_applets_container', 'id_applets_container',
'id_game_applets_container', 'id_game_applets_container',
'id_gk_sections_container',
'id_wallet_applets_container', 'id_wallet_applets_container',
]); ]);
@@ -33,5 +37,6 @@ document.body.addEventListener('htmx:afterSwap', (e) => {
document.querySelectorAll('.gear-btn').forEach(gear => { document.querySelectorAll('.gear-btn').forEach(gear => {
const menu = document.getElementById(gear.dataset.menuTarget); const menu = document.getElementById(gear.dataset.menuTarget);
if (menu) menu.style.display = 'none'; if (menu) menu.style.display = 'none';
gear.classList.remove('active');
}); });
}); });

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BillboardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.billboard'

View File

View File

@@ -0,0 +1,189 @@
from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet
from apps.drama.models import GameEvent, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
def _seed_billboard_applets():
for slug, name, cols, rows in [
("billboard-my-scrolls", "My Scrolls", 4, 3),
("billboard-my-contacts", "Contacts", 4, 3),
("billboard-most-recent", "Most Recent", 8, 6),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
)
class BillboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billboard.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_uses_billboard_template(self):
response = self.client.get("/billboard/")
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_passes_applets_context(self):
response = self.client.get("/billboard/")
self.assertIn("applets", response.context)
slugs = [e["applet"].slug for e in response.context["applets"]]
self.assertIn("billboard-my-scrolls", slugs)
self.assertIn("billboard-my-contacts", slugs)
self.assertIn("billboard-most-recent", slugs)
def test_passes_my_rooms_context(self):
room = Room.objects.create(name="Test Room", owner=self.user)
response = self.client.get("/billboard/")
self.assertIn(room, response.context["my_rooms"])
def test_passes_recent_room_and_events(self):
room = Room.objects.create(name="Test Room", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
self.assertEqual(response.context["recent_room"], room)
self.assertEqual(len(response.context["recent_events"]), 1)
def test_recent_events_capped_at_36(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for i in range(40):
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
self.assertEqual(len(response.context["recent_events"]), 36)
def test_recent_events_in_chronological_order(self):
room = Room.objects.create(name="Test Room", owner=self.user)
for _ in range(3):
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
events = response.context["recent_events"]
timestamps = [e.timestamp for e in events]
self.assertEqual(timestamps, sorted(timestamps))
def test_recent_room_is_none_when_no_events(self):
response = self.client.get("/billboard/")
self.assertIsNone(response.context["recent_room"])
self.assertEqual(list(response.context["recent_events"]), [])
class ToggleBillboardAppletsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@toggle.io")
self.client.force_login(self.user)
_seed_billboard_applets()
def test_toggle_hides_unchecked_applets(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["billboard-my-scrolls"]},
)
self.assertEqual(response.status_code, 302)
from apps.applets.models import UserApplet
contacts = Applet.objects.get(slug="billboard-my-contacts")
ua = UserApplet.objects.get(user=self.user, applet=contacts)
self.assertFalse(ua.visible)
def test_toggle_returns_partial_on_htmx(self):
response = self.client.post(
reverse("billboard:toggle_applets"),
{"applets": ["billboard-my-scrolls"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
class BillscrollViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@billscroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
def test_uses_room_scroll_template(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertTemplateUsed(response, "apps/billboard/room_scroll.html")
def test_passes_events_context(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertIn("events", response.context)
self.assertEqual(response.context["events"].count(), 1)
def test_passes_page_class_billscroll(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["page_class"], "page-billscroll")
def test_passes_scroll_position_zero_when_none_saved(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 0)
def test_passes_saved_scroll_position_in_context(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 250)
def test_scroll_renders_event_body_and_time_columns(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertContains(response, 'class="drama-event-body"')
self.assertContains(response, 'class="drama-event-time"')
class SaveScrollPositionTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@savescroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_post_saves_scroll_position(self):
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 300},
)
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
self.assertEqual(sp.position, 300)
def test_post_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 450},
)
self.assertEqual(
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
)
def test_post_returns_204(self):
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 204)
def test_post_requires_login(self):
self.client.logout()
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 302)

View File

@@ -0,0 +1,12 @@
from django.urls import path
from apps.billboard import views
app_name = "billboard"
urlpatterns = [
path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
]

View File

@@ -0,0 +1,92 @@
from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q
from django.shortcuts import redirect, render
from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.drama.models import GameEvent, ScrollPosition
from apps.epic.models import GateSlot, Room, RoomInvite
@login_required(login_url="/")
def billboard(request):
my_rooms = Room.objects.filter(
Q(owner=request.user) |
Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct().order_by("-created_at")
recent_room = (
Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
)
.annotate(last_event=Max("events__timestamp"))
.filter(last_event__isnull=False)
.order_by("-last_event")
.distinct()
.first()
)
recent_events = (
list(
recent_room.events
.select_related("actor")
.exclude(verb=GameEvent.SIG_UNREADY)
.exclude(verb=GameEvent.SIG_READY, data__retracted=True)
.order_by("-timestamp")[:36]
)[::-1]
if recent_room else []
)
return render(request, "apps/billboard/billboard.html", {
"my_rooms": my_rooms,
"recent_room": recent_room,
"recent_events": recent_events,
"viewer": request.user,
"applets": applet_context(request.user, "billboard"),
"page_class": "page-billboard",
})
@login_required(login_url="/")
def toggle_billboard_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="billboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", {
"applets": applet_context(request.user, "billboard"),
})
return redirect("billboard:billboard")
@login_required(login_url="/")
def room_scroll(request, room_id):
room = Room.objects.get(id=room_id)
events = room.events.select_related("actor").all()
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
return render(request, "apps/billboard/room_scroll.html", {
"room": room,
"events": events,
"viewer": request.user,
"scroll_position": sp.position if sp else 0,
"page_class": "page-billscroll",
})
@login_required(login_url="/")
def save_scroll_position(request, room_id):
if request.method != "POST":
from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["POST"])
room = Room.objects.get(id=room_id)
position = int(request.POST.get("position", 0))
ScrollPosition.objects.update_or_create(
user=request.user, room=room,
defaults={"position": position},
)
from django.http import HttpResponse
return HttpResponse(status=204)

View File

@@ -0,0 +1,43 @@
// console.log("apps/scripts/dashboard.js loading");
const initialize = (inputSelector) => {
// console.log("initialize called!");
const textInput = document.querySelector(inputSelector);
if (!textInput) return;
textInput.oninput = () => {
// console.log("oninput triggered");
textInput.classList.remove("is-invalid");
};
};
const bindPaletteWheel = () => {
document.querySelectorAll('.palette-scroll').forEach(el => {
el.addEventListener('wheel', (e) => {
e.preventDefault();
el.scrollLeft += e.deltaY;
}, { passive: false });
});
};
const bindPaletteForms = () => {
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const resp = await fetch(form.action, {
method: "POST",
headers: { "Accept": "application/json" },
body: new FormData(form, e.submitter),
});
if (!resp.ok) return;
const { palette } = await resp.json();
// Swap body palette class
[...document.body.classList]
.filter(c => c.startsWith("palette-"))
.forEach(c => document.body.classList.remove(c));
document.body.classList.add(palette);
// Update active swatch indicator
document.querySelectorAll(".swatch").forEach(sw => {
sw.classList.toggle("active", sw.classList.contains(palette));
});
});
});
};

View File

@@ -3,14 +3,10 @@
var dialog = document.getElementById('id_kit_bag_dialog'); var dialog = document.getElementById('id_kit_bag_dialog');
if (!btn || !dialog) return; if (!btn || !dialog) return;
dialog.addEventListener('close', function () {
btn.classList.remove('active');
clearSelection();
});
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
if (dialog.open) { if (dialog.hasAttribute('open')) {
dialog.close(); dialog.removeAttribute('open');
btn.classList.remove('active');
return; return;
} }
fetch(btn.dataset.kitUrl, { fetch(btn.dataset.kitUrl, {
@@ -30,18 +26,20 @@
// Escape key // Escape key
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && dialog.open) { if (e.key === 'Escape' && dialog.hasAttribute('open')) {
dialog.close(); dialog.removeAttribute('open');
btn.classList.remove('active');
} }
}); });
// Click outside (but not on the rails button — let that flow through) // Click outside (but not on the rails button — let that flow through)
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (!dialog.open) return; if (!dialog.hasAttribute('open')) return;
if (dialog.contains(e.target)) return; if (dialog.contains(e.target)) return;
if (e.target === btn || btn.contains(e.target)) return; if (e.target === btn || btn.contains(e.target)) return;
if (e.target.closest('button.token-rails')) return; if (e.target.closest('button.token-rails')) return;
dialog.close(); dialog.removeAttribute('open');
btn.classList.remove('active');
}); });
// Inject token_id before token-rails form submits // Inject token_id before token-rails form submits
@@ -57,13 +55,29 @@
hidden.name = 'token_id'; hidden.name = 'token_id';
hidden.value = window._kitTokenId; hidden.value = window._kitTokenId;
form.appendChild(hidden); form.appendChild(hidden);
if (dialog.open) dialog.close(); if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
}); });
function attachTooltip(el) {
el.addEventListener('mouseenter', function () {
var tooltip = el.querySelector('.token-tooltip');
if (!tooltip) return;
var rect = el.getBoundingClientRect();
tooltip.style.position = 'fixed';
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
tooltip.style.left = rect.left + 'px';
tooltip.style.display = 'block';
});
el.addEventListener('mouseleave', function () {
var tooltip = el.querySelector('.token-tooltip');
if (tooltip) tooltip.style.display = '';
});
}
function attachCardListeners() { function attachCardListeners() {
dialog.querySelectorAll('.kit-card').forEach(function (card) { dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
card.addEventListener('click', function () { card.addEventListener('click', function () {
dialog.querySelectorAll('.kit-card.selected').forEach(function (c) { dialog.querySelectorAll('.token[data-token-id].selected').forEach(function (c) {
c.classList.remove('selected'); c.classList.remove('selected');
}); });
card.classList.add('selected'); card.classList.add('selected');
@@ -71,12 +85,11 @@
var slot = document.querySelector('.token-slot'); var slot = document.querySelector('.token-slot');
if (slot) slot.classList.add('ready'); if (slot) slot.classList.add('ready');
}); });
attachTooltip(card);
}); });
dialog.querySelectorAll('.kit-bag-deck').forEach(attachTooltip);
} }
function clearSelection() {
window._kitTokenId = null;
var slot = document.querySelector('.token-slot');
if (slot) slot.classList.remove('ready');
}
}()); }());

View File

@@ -21,27 +21,7 @@ const initWallet = () => {
saveBtn.hidden = false; saveBtn.hidden = false;
cancelBtn.hidden = false; cancelBtn.hidden = false;
const section = addBtn.closest('section'); const section = addBtn.closest('section');
const rowPx = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); section.style.setProperty('--applet-rows', '15');
const updateRows = () => {
const sectionTop = section.getBoundingClientRect().top;
let maxBottom = sectionTop;
for (const child of section.children) {
if (child.hidden) continue;
maxBottom = Math.max(maxBottom, child.getBoundingClientRect().bottom);
}
const padBot = parseFloat(getComputedStyle(section).paddingBottom);
const rows = Math.ceil((maxBottom - sectionTop + padBot) / rowPx) + 1;
section.style.setProperty('--applet-rows', String(rows));
};
paymentEl.on('ready', () => {
updateRows();
const stripeContainer = document.getElementById('id_stripe_payment_element');
if (stripeContainer) {
const obs = new ResizeObserver(updateRows);
obs.observe(stripeContainer);
section._stripeObs = obs;
}
});
}); });
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
@@ -68,8 +48,7 @@ const initWallet = () => {
saveBtn.hidden = true; saveBtn.hidden = true;
cancelBtn.hidden = true; cancelBtn.hidden = true;
const section = cancelBtn.closest('section'); const section = cancelBtn.closest('section');
section.style.setProperty('--applet-rows', '2'); section.style.setProperty('--applet-rows', '3');
if (section._stripeObs) { section._stripeObs.disconnect(); section._stripeObs = null; }
}); });
cancelBtn.addEventListener('click', () => { cancelBtn.addEventListener('click', () => {
@@ -81,8 +60,7 @@ const initWallet = () => {
saveBtn.hidden = true; saveBtn.hidden = true;
cancelBtn.hidden = true; cancelBtn.hidden = true;
const section = cancelBtn.closest('section'); const section = cancelBtn.closest('section');
section.style.setProperty('--applet-rows', '2'); section.style.setProperty('--applet-rows', '3');
if (section._stripeObs) { section._stripeObs.disconnect(); section._stripeObs = null; }
}); });
}; };

View File

@@ -1,10 +0,0 @@
// console.log("apps/scripts/dashboard.js loading");
const initialize = (inputSelector) => {
// console.log("initialize called!");
const textInput = document.querySelector(inputSelector);
if (!textInput) return;
textInput.oninput = () => {
// console.log("oninput triggered");
textInput.classList.remove("is-invalid");
};
};

View File

@@ -299,11 +299,30 @@ class SetPaletteTest(TestCase):
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"}) response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
self.assertRedirects(response, "/", fetch_redirect_response=False) self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_set_palette_returns_json_when_requested(self):
response = self.client.post(
"/dashboard/set_palette",
data={"palette": "palette-sepia"},
headers={"Accept": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"palette": "palette-sepia"})
def test_locked_palette_returns_unchanged_json(self):
response = self.client.post(
"/dashboard/set_palette",
data={"palette": "palette-nirvana"},
headers={"Accept": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"palette": "palette-default"})
def test_dashboard_contains_set_palette_form(self): def test_dashboard_contains_set_palette_form(self):
response = self.client.get(self.url) response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[action="/dashboard/set_palette"]') forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
self.assertEqual(len(forms), 1) unlocked = [p for p in response.context["palettes"] if not p["locked"]]
self.assertEqual(len(forms), len(unlocked))
def test_active_palette_swatch_has_active_class(self): def test_active_palette_swatch_has_active_class(self):
response = self.client.get(self.url) response = self.client.get(self.url)

View File

@@ -32,7 +32,7 @@ class WalletViewTest(TestCase):
[_] = self.parsed.cssselect("#id_coin_on_a_string") [_] = self.parsed.cssselect("#id_coin_on_a_string")
def test_wallet_page_shows_free_token(self): def test_wallet_page_shows_free_token(self):
[_] = self.parsed.cssselect("#id_free_token_0") [_] = self.parsed.cssselect("#id_free_token")
def test_wallet_page_shows_payment_methods_section(self): def test_wallet_page_shows_payment_methods_section(self):
[_] = self.parsed.cssselect("#id_add_payment_method") [_] = self.parsed.cssselect("#id_add_payment_method")
@@ -61,7 +61,7 @@ class WalletViewAppletContextTest(TestCase):
) )
Applet.objects.get_or_create( Applet.objects.get_or_create(
slug="wallet-payment", slug="wallet-payment",
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 2, "context": "wallet"}, defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
) )
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -96,7 +96,7 @@ class ToggleWalletAppletsTest(TestCase):
)[0] )[0]
Applet.objects.get_or_create( Applet.objects.get_or_create(
slug="wallet-payment", slug="wallet-payment",
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 2, "context": "wallet"}, defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
) )
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
@@ -16,9 +17,17 @@ from apps.lyric.models import PaymentMethod, Token, User, Wallet
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"] APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
UNLOCKED_PALETTES = frozenset(["palette-default"]) UNLOCKED_PALETTES = frozenset([
"palette-default",
"palette-sepia",
"palette-oblivion-light",
"palette-monochrome-dark",
])
PALETTES = [ PALETTES = [
{"name": "palette-default", "label": "Earthman", "locked": False}, {"name": "palette-default", "label": "Earthman", "locked": False},
{"name": "palette-sepia", "label": "Sepia", "locked": False},
{"name": "palette-oblivion-light", "label": "Oblivion (Light)", "locked": False},
{"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "locked": False},
{"name": "palette-nirvana", "label": "Nirvana", "locked": True}, {"name": "palette-nirvana", "label": "Nirvana", "locked": True},
{"name": "palette-sheol", "label": "Sheol", "locked": True}, {"name": "palette-sheol", "label": "Sheol", "locked": True},
{"name": "palette-inferno", "label": "Inferno", "locked": True}, {"name": "palette-inferno", "label": "Inferno", "locked": True},
@@ -113,6 +122,8 @@ def set_palette(request):
if palette in UNLOCKED_PALETTES: if palette in UNLOCKED_PALETTES:
request.user.palette = palette request.user.palette = palette
request.user.save(update_fields=["palette"]) request.user.save(update_fields=["palette"])
if "application/json" in request.headers.get("Accept", ""):
return JsonResponse({"palette": request.user.palette})
return redirect("home") return redirect("home")
@login_required(login_url="/") @login_required(login_url="/")
@@ -148,8 +159,14 @@ def wallet(request):
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(), "pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")),
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
"free_count": request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).count(),
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
"applets": applet_context(request.user, "wallet"), "applets": applet_context(request.user, "wallet"),
"page_class": "page-wallet", "page_class": "page-wallet",
}) })
@@ -158,7 +175,19 @@ def wallet(request):
@login_required(login_url="/") @login_required(login_url="/")
def kit_bag(request): def kit_bag(request):
tokens = list(request.user.tokens.all()) tokens = list(request.user.tokens.all())
return render(request, "core/_partials/_kit_bag_panel.html", {"tokens": tokens}) free_tokens = sorted(
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
key=lambda t: t.expires_at,
)
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
return render(request, "core/_partials/_kit_bag_panel.html", {
"equipped_deck": request.user.equipped_deck,
"equipped_trinket": request.user.equipped_trinket,
"free_token": free_tokens[0] if free_tokens else None,
"free_count": len(free_tokens),
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
"tithe_count": len(tithe_tokens),
})
@login_required(login_url="/") @login_required(login_url="/")
def toggle_wallet_applets(request): def toggle_wallet_applets(request):

View File

19
src/apps/drama/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from apps.drama.models import GameEvent
@admin.register(GameEvent)
class GameEventAdmin(admin.ModelAdmin):
list_display = ("timestamp", "room", "actor", "verb")
list_filter = ("verb",)
readonly_fields = ("room", "actor", "verb", "data", "timestamp")
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return False

6
src/apps/drama/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DramaConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.drama'

View File

@@ -0,0 +1,32 @@
# Generated by Django 6.0 on 2026-03-19 18:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('epic', '0006_table_status_and_table_seat'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GameEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('verb', models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed')], max_length=30)),
('data', models.JSONField(default=dict)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='game_events', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='epic.room')),
],
options={
'ordering': ['timestamp'],
},
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-03-24 21:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0001_initial'),
('epic', '0006_table_status_and_table_seat'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ScrollPosition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('position', models.PositiveIntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to='epic.room')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'room')},
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-04-12 23:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0002_scrollposition'),
]
operations = [
migrations.AlterField(
model_name='gameevent',
name='verb',
field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn')], max_length=30),
),
]

View File

170
src/apps/drama/models.py Normal file
View File

@@ -0,0 +1,170 @@
from django.conf import settings
from django.db import models
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
# Later: replace with per-actor lookup when User model gains a pronouns field.
PRONOUN_SUBJ = "yo"
PRONOUN_OBJ = "yo"
PRONOUN_POSS = "yos"
class GameEvent(models.Model):
# Gate phase
ROOM_CREATED = "room_created"
SLOT_RESERVED = "slot_reserved"
SLOT_FILLED = "slot_filled"
SLOT_RETURNED = "slot_returned"
SLOT_RELEASED = "slot_released"
INVITE_SENT = "invite_sent"
# Role Select phase
ROLE_SELECT_STARTED = "role_select_started"
ROLE_SELECTED = "role_selected"
ROLES_REVEALED = "roles_revealed"
# Sig Select phase
SIG_READY = "sig_ready"
SIG_UNREADY = "sig_unready"
VERB_CHOICES = [
(ROOM_CREATED, "Room created"),
(SLOT_RESERVED, "Gate slot reserved"),
(SLOT_FILLED, "Gate slot filled"),
(SLOT_RETURNED, "Gate slot returned"),
(SLOT_RELEASED, "Gate slot released"),
(INVITE_SENT, "Invite sent"),
(ROLE_SELECT_STARTED, "Role select started"),
(ROLE_SELECTED, "Role selected"),
(ROLES_REVEALED, "Roles revealed"),
(SIG_READY, "Sig claim staked"),
(SIG_UNREADY, "Sig claim withdrawn"),
]
room = models.ForeignKey(
"epic.Room", on_delete=models.CASCADE, related_name="events",
)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="game_events",
)
verb = models.CharField(max_length=30, choices=VERB_CHOICES)
data = models.JSONField(default=dict)
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["timestamp"]
def to_prose(self):
"""Return a human-readable action description (actor rendered separately in template)."""
d = self.data
if self.verb == self.SLOT_FILLED:
_token_names = {
"coin": "Coin-on-a-String", "Free": "Free Token",
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
}
code = d.get("token_type", "token")
token = d.get("token_display") or _token_names.get(code, code)
days = d.get("renewal_days", 7)
slot = d.get("slot_number", "?")
return f"deposits a {token} for slot {slot} (expires in {days} days)."
if self.verb == self.SLOT_RESERVED:
return "reserves a seat"
if self.verb == self.SLOT_RETURNED:
return "withdraws from the gate"
if self.verb == self.SLOT_RELEASED:
return f"releases slot {d.get('slot_number', '?')}"
if self.verb == self.ROOM_CREATED:
return "opens this room"
if self.verb == self.INVITE_SENT:
return "sends an invitation"
if self.verb == self.ROLE_SELECT_STARTED:
return "Role selection begins"
if self.verb == self.ROLE_SELECTED:
_role_names = {
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
}
_chair_order = ["PC", "NC", "EC", "SC", "AC", "BC"]
_ordinals = ["1st", "2nd", "3rd", "4th", "5th", "6th"]
code = d.get("role", "?")
role = d.get("role_display") or _role_names.get(code, code)
try:
ordinal = _ordinals[_chair_order.index(code)]
except ValueError:
ordinal = "?"
return f"assumes {ordinal} Chair; yo will start the game as the {role}."
if self.verb == self.ROLES_REVEALED:
return "All roles assigned"
if self.verb == self.SIG_READY:
card_name = d.get("card_name", "a card")
corner_rank = d.get("corner_rank", "")
suit_icon = d.get("suit_icon", "")
if corner_rank:
icon_html = f' <i class="fa-solid {suit_icon}"></i>' if suit_icon else ""
abbrev = f" ({corner_rank}{icon_html})"
else:
abbrev = ""
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}."
if self.verb == self.SIG_UNREADY:
return f"disembodies {PRONOUN_POSS} Significator."
return self.verb
@property
def struck(self):
"""True when this SIG_READY event was subsequently retracted (WAIT NVM)."""
return self.data.get("retracted", False)
def to_activity(self, base_url):
"""Serialise this event as an AS2 Activity dict, or None if unsupported."""
if not self.actor or not self.actor.username:
return None
actor_url = f"{base_url}/ap/users/{self.actor.username}/"
room_url = f"{base_url}/gameboard/room/{self.room_id}/"
if self.verb == self.SLOT_FILLED:
return {
"type": "earthman:JoinGate",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
if self.verb == self.ROLE_SELECTED:
return {
"type": "earthman:SelectRole",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
if self.verb == self.ROOM_CREATED:
return {
"type": "Create",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
return None
def __str__(self):
actor = self.actor.email if self.actor else "system"
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor}{self.verb}"
class ScrollPosition(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="scroll_positions",
)
room = models.ForeignKey(
"epic.Room", on_delete=models.CASCADE,
related_name="scroll_positions",
)
position = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("user", "room")]
def __str__(self):
return f"{self.user.email} @ {self.room.name}: {self.position}px"
def record(room, verb, actor=None, **data):
"""Record a game event in the drama log."""
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)

View File

View File

@@ -0,0 +1,116 @@
from django.test import TestCase
from django.db import IntegrityError
from apps.drama.models import GameEvent, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
class GameEventModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_record_creates_game_event(self):
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
self.assertEqual(GameEvent.objects.count(), 1)
self.assertEqual(event.room, self.room)
self.assertEqual(event.actor, self.user)
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
def test_record_without_actor(self):
event = record(self.room, GameEvent.ROOM_CREATED)
self.assertIsNone(event.actor)
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
def test_events_ordered_by_timestamp(self):
record(self.room, GameEvent.ROOM_CREATED)
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
verbs = list(GameEvent.objects.values_list("verb", flat=True))
self.assertEqual(verbs, [
GameEvent.ROOM_CREATED,
GameEvent.SLOT_RESERVED,
GameEvent.SLOT_FILLED,
])
def test_str_includes_actor_and_verb(self):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
self.assertIn("actor@test.io", str(event))
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
# ── to_prose — ROLE_SELECTED ──────────────────────────────────────────
def test_role_selected_prose_uses_ordinal_chair(self):
for role, ordinal in [("PC", "1st"), ("NC", "2nd"), ("EC", "3rd"),
("SC", "4th"), ("AC", "5th"), ("BC", "6th")]:
with self.subTest(role=role):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role=role, role_display="")
self.assertIn(f"assumes {ordinal} Chair", event.to_prose())
def test_role_selected_prose_includes_role_name(self):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role="PC", role_display="Player")
prose = event.to_prose()
self.assertIn("Player", prose)
self.assertIn("yo will start the game", prose)
# ── to_prose — SIG_READY ─────────────────────────────────────────────
def test_sig_ready_prose_embodies_card_with_rank_and_icon(self):
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands", corner_rank="M",
suit_icon="fa-wand-sparkles")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
self.assertIn("(M", prose)
self.assertIn("fa-wand-sparkles", prose)
def test_sig_ready_prose_omits_icon_when_none(self):
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="The Wanderer", corner_rank="0", suit_icon="")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
self.assertNotIn("fa-", prose)
def test_sig_ready_prose_degrades_without_corner_rank(self):
# Old events recorded before this change have no corner_rank key
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
self.assertNotIn("(", prose)
def test_str_without_actor_shows_system(self):
event = record(self.room, GameEvent.ROLES_REVEALED)
self.assertIn("system", str(event))
class ScrollPositionModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_can_save_scroll_position(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
self.assertEqual(ScrollPosition.objects.count(), 1)
self.assertEqual(sp.position, 150)
def test_default_position_is_zero(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
self.assertEqual(sp.position, 0)
def test_unique_per_user_and_room(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
with self.assertRaises(IntegrityError):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
def test_upsert_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
ScrollPosition.objects.update_or_create(
user=self.user, room=self.room,
defaults={"position": 200},
)
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)

View File

@@ -1,3 +1,18 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import DeckVariant, TarotCard
@admin.register(DeckVariant)
class DeckVariantAdmin(admin.ModelAdmin):
list_display = ["name", "slug", "card_count", "is_default"]
prepopulated_fields = {"slug": ["name"]}
@admin.register(TarotCard)
class TarotCardAdmin(admin.ModelAdmin):
list_display = ["name", "deck_variant", "arcana", "suit", "number", "group", "slug"]
list_filter = ["deck_variant", "arcana", "suit"]
search_fields = ["name", "slug", "correspondence", "group"]
readonly_fields = ["slug", "correspondence", "group"]
ordering = ["deck_variant", "arcana", "suit", "number"]

View File

@@ -1 +1,94 @@
# RoomConsumer goes here from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
LEVITY_ROLES = {"PC", "NC", "SC"}
GRAVITY_ROLES = {"BC", "EC", "AC"}
class RoomConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
self.group_name = f"room_{self.room_id}"
await self.channel_layer.group_add(self.group_name, self.channel_name)
self.cursor_group = None
user = self.scope.get("user")
if user and user.is_authenticated:
seat = await self._get_seat(user)
if seat:
if seat.role in LEVITY_ROLES:
self.cursor_group = f"cursors_{self.room_id}_levity"
elif seat.role in GRAVITY_ROLES:
self.cursor_group = f"cursors_{self.room_id}_gravity"
if self.cursor_group:
await self.channel_layer.group_add(self.cursor_group, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
if self.cursor_group:
await self.channel_layer.group_discard(self.cursor_group, self.channel_name)
async def receive_json(self, content):
msg_type = content.get("type")
if msg_type == "cursor_move" and self.cursor_group:
await self.channel_layer.group_send(
self.cursor_group,
{"type": "cursor_move", "x": content.get("x"), "y": content.get("y")},
)
elif msg_type == "sig_hover" and self.cursor_group:
await self.channel_layer.group_send(
self.cursor_group,
{
"type": "sig_hover",
"card_id": content.get("card_id"),
"role": content.get("role"),
"active": content.get("active"),
},
)
@database_sync_to_async
def _get_seat(self, user):
from apps.epic.models import TableSeat
return TableSeat.objects.filter(room_id=self.room_id, gamer=user).first()
async def gate_update(self, event):
await self.send_json(event)
async def role_select_start(self, event):
await self.send_json(event)
async def turn_changed(self, event):
await self.send_json(event)
async def all_roles_filled(self, event):
await self.send_json(event)
async def sig_select_started(self, event):
await self.send_json(event)
async def sig_selected(self, event):
await self.send_json(event)
async def sig_hover(self, event):
await self.send_json(event)
async def sig_reserved(self, event):
await self.send_json(event)
async def countdown_start(self, event):
await self.send_json(event)
async def countdown_cancel(self, event):
await self.send_json(event)
async def polarity_room_done(self, event):
await self.send_json(event)
async def pick_sky_available(self, event):
await self.send_json(event)
async def cursor_move(self, event):
await self.send_json(event)

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0004_alter_room_gate_status'),
]
operations = [
migrations.AddField(
model_name='gateslot',
name='debited_token_type',
field=models.CharField(max_length=8, null=True, blank=True),
),
migrations.AddField(
model_name='gateslot',
name='debited_token_expires_at',
field=models.DateTimeField(null=True, blank=True),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0 on 2026-03-17 00:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0005_gateslot_debited_token_fields'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='room',
name='table_status',
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
),
migrations.CreateModel(
name='TableSeat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slot_number', models.IntegerField()),
('role', models.CharField(blank=True, choices=[('PC', 'Player'), ('BC', 'Builder'), ('SC', 'Shepherd'), ('AC', 'Alchemist'), ('NC', 'Narrator'), ('EC', 'Economist')], max_length=2, null=True)),
('role_revealed', models.BooleanField(default=False)),
('seat_position', models.IntegerField(blank=True, null=True)),
('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='table_seats', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_seats', to='epic.room')),
],
),
]

View File

@@ -0,0 +1,39 @@
# Generated by Django 6.0 on 2026-03-24 23:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0006_table_status_and_table_seat'),
]
operations = [
migrations.CreateModel(
name='TarotCard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('arcana', models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana')], max_length=5)),
('suit', models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True)),
('number', models.IntegerField()),
('slug', models.SlugField(unique=True)),
('keywords_upright', models.JSONField(default=list)),
('keywords_reversed', models.JSONField(default=list)),
],
options={
'ordering': ['arcana', 'suit', 'number'],
},
),
migrations.CreateModel(
name='TarotDeck',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('drawn_card_ids', models.JSONField(default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('room', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tarot_deck', to='epic.room')),
],
),
]

View File

@@ -0,0 +1,164 @@
from django.db import migrations
MAJOR_ARCANA = [
(0, "The Fool", "the-fool", ["beginnings", "spontaneity", "freedom"], ["recklessness", "naivety", "risk"]),
(1, "The Magician", "the-magician", ["willpower", "skill", "resourcefulness"], ["manipulation", "untapped potential", "deceit"]),
(2, "The High Priestess", "the-high-priestess", ["intuition", "mystery", "inner knowledge"], ["secrets", "disconnection", "withdrawal"]),
(3, "The Empress", "the-empress", ["fertility", "abundance", "nurturing"], ["dependence", "smothering", "creative block"]),
(4, "The Emperor", "the-emperor", ["authority", "structure", "stability"], ["rigidity", "domination", "inflexibility"]),
(5, "The Hierophant", "the-hierophant", ["tradition", "conformity", "institutions"], ["rebellion", "unconventionality", "challenge"]),
(6, "The Lovers", "the-lovers", ["love", "harmony", "choice"], ["disharmony", "imbalance", "misalignment"]),
(7, "The Chariot", "the-chariot", ["control", "willpower", "victory"], ["aggression", "lack of direction", "defeat"]),
(8, "Strength", "strength", ["courage", "patience", "compassion"], ["self-doubt", "weakness", "insecurity"]),
(9, "The Hermit", "the-hermit", ["introspection", "guidance", "solitude"], ["isolation", "loneliness", "withdrawal"]),
(10, "Wheel of Fortune", "wheel-of-fortune", ["change", "cycles", "fate"], ["bad luck", "resistance", "clinging to control"]),
(11, "Justice", "justice", ["fairness", "truth", "cause and effect"], ["injustice", "dishonesty", "avoidance"]),
(12, "The Hanged Man", "the-hanged-man", ["pause", "surrender", "new perspective"], ["stalling", "resistance", "indecision"]),
(13, "Death", "death", ["endings", "transition", "transformation"], ["fear of change", "stagnation", "resistance"]),
(14, "Temperance", "temperance", ["balance", "patience", "moderation"], ["imbalance", "excess", "lack of harmony"]),
(15, "The Devil", "the-devil", ["bondage", "materialism", "shadow self"], ["detachment", "freedom", "releasing control"]),
(16, "The Tower", "the-tower", ["sudden change", "upheaval", "revelation"], ["avoidance", "fear of change", "delaying disaster"]),
(17, "The Star", "the-star", ["hope", "renewal", "inspiration"], ["despair", "insecurity", "hopelessness"]),
(18, "The Moon", "the-moon", ["illusion", "fear", "the unconscious"], ["confusion", "misinterpretation", "clarity"]),
(19, "The Sun", "the-sun", ["positivity", "success", "vitality"], ["negativity", "depression", "sadness"]),
(20, "Judgement", "judgement", ["reflection", "reckoning", "absolution"], ["self-doubt", "lack of self-awareness", "loathing"]),
(21, "The World", "the-world", ["completion", "integration", "accomplishment"], ["incompletion", "no closure", "shortcuts"]),
]
MINOR_SUITS = [
("WANDS", "wands"),
("CUPS", "cups"),
("SWORDS", "swords"),
("PENTACLES", "pentacles"),
]
MINOR_NAMES = [
(1, "Ace", "ace"),
(2, "Two", "two"),
(3, "Three", "three"),
(4, "Four", "four"),
(5, "Five", "five"),
(6, "Six", "six"),
(7, "Seven", "seven"),
(8, "Eight", "eight"),
(9, "Nine", "nine"),
(10, "Ten", "ten"),
(11, "Page", "page"),
(12, "Knight", "knight"),
(13, "Queen", "queen"),
(14, "King", "king"),
]
# Keywords: [suit][number-1] → (upright_list, reversed_list)
MINOR_KEYWORDS = {
"WANDS": [
(["inspiration", "new venture", "spark"], ["delays", "lack of motivation", "false start"]),
(["planning", "progress", "decisions"], ["impatience", "lack of planning", "hesitation"]),
(["expansion", "foresight", "enterprise"], ["obstacles", "lack of foresight", "delays"]),
(["celebration", "harmony", "homecoming"], ["lack of support", "transience", "home conflicts"]),
(["conflict", "competition", "tension"], ["avoiding conflict", "compromise", "truce"]),
(["victory", "recognition", "progress"], ["excess pride", "lack of recognition", "fall"]),
(["challenge", "courage", "competition"], ["anxiety", "giving up", "overwhelmed"]),
(["rapid action", "adventure", "change"], ["haste", "scattered energy", "delays"]),
(["resilience", "persistence", "last stand"], ["exhaustion", "giving up", "surrender"]),
(["completion", "celebration", "travel"], ["burdens", "oppression", "carrying too much"]),
(["exploration", "enthusiasm", "adventure"], ["hasty decisions", "scattered energy", "immaturity"]),
(["energy", "passion", "adventure"], ["scattered energy", "frustration", "aggression"]),
(["confidence", "independence", "courage"], ["selfishness", "jealousy", "insecurity"]),
(["big picture", "leadership", "vision"], ["impulsiveness", "haste", "overconfidence"]),
],
"CUPS": [
(["new feelings", "intuition", "opportunity"], ["blocked creativity", "emptiness", "hesitation"]),
(["partnership", "unity", "celebration"], ["imbalance", "broken bonds", "misalignment"]),
(["creativity", "community", "abundance"], ["independence", "isolation", "looking inward"]),
(["contemplation", "apathy", "reevaluation"], ["withdrawal", "boredom", "seeking motivation"]),
(["loss", "grief", "disappointment"], ["acceptance", "moving on", "forgiveness"]),
(["nostalgia", "reunion", "joy"], ["living in the past", "naivety", "unrealistic"]),
(["illusion", "fantasy", "wishful thinking"], ["alignment", "clarity", "sobriety"]),
(["disappointment", "abandonment", "walking away"], ["hopelessness", "aimlessness", "stagnation"]),
(["contentment", "fulfilment", "satisfaction"], ["inner happiness", "materialism", "indulgence"]),
(["divine love", "bliss", "fulfilment"], ["inner happiness", "alignment", "personal values"]),
(["sensitivity", "creativity", "intuition"], ["insecurity", "emotional immaturity", "creative blocks"]),
(["compassion", "romanticism", "diplomacy"], ["moodiness", "emotional manipulation", "deception"]),
(["compassion", "empathy", "nurturing"], ["emotional insecurity", "over-giving", "neglect"]),
(["emotional maturity", "diplomacy", "wisdom"], ["manipulation", "moodiness", "coldness"]),
],
"SWORDS": [
(["raw power", "breakthrough", "clarity"], ["confusion", "brutality", "mental chaos"]),
(["difficult choices", "stalemate", "truce"], ["indecision", "lies", "confusion"]),
(["heartbreak", "sorrow", "grief"], ["recovery", "forgiveness", "moving on"]),
(["rest", "restoration", "retreat"], ["restlessness", "burnout", "illness"]),
(["defeat", "change", "transition"], ["resistance to change", "inability to move"]),
(["victory", "success", "ambition"], ["an eye for an eye", "dishonour", "manipulation"]),
(["deception", "trickery", "tactics"], ["imposter syndrome", "coming clean", "rethinking"]),
(["restriction", "isolation", "imprisonment"], ["self-limiting beliefs", "inner critic", "opening up"]),
(["anxiety", "worry", "fear"], ["recovery from anxiety", "inner turmoil", "secrets"]),
(["ruin", "painful endings", "loss"], ["recovery", "regeneration", "resisting an end"]),
(["new ideas", "mental agility", "curiosity"], ["manipulation", "all talk no action", "ruthlessness"]),
(["action", "impulsiveness", "ambition"], ["no direction", "disregard for consequences"]),
(["clarity", "directness", "structure"], ["coldness", "cruelty", "manipulation"]),
(["mental clarity", "truth", "authority"], ["abuse of power", "manipulation", "coldness"]),
],
"PENTACLES": [
(["opportunity", "new venture", "manifestation"], ["lost opportunity", "lack of planning", "scarcity"]),
(["juggling resources", "flexibility", "fun"], ["imbalance", "disorganisation", "overwhelm"]),
(["teamwork", "building", "apprenticeship"], ["lack of teamwork", "disharmony", "misalignment"]),
(["stability", "security", "conservation"], ["greed", "stinginess", "possessiveness"]),
(["isolation", "insecurity", "worry"], ["recovery from loss", "overcoming hardship"]),
(["generosity", "charity", "community"], ["strings attached", "power dynamics", "inequality"]),
(["hard work", "perseverance", "diligence"], ["lack of reward", "laziness", "low quality"]),
(["apprenticeship", "education", "skill"], ["perfectionism", "misdirected activity", "misuse"]),
(["abundance", "luxury", "self-sufficiency"], ["overindulgence", "superficiality", "materialism"]),
(["wealth", "financial security", "achievement"], ["financial failure", "greed", "lost success"]),
(["ambition", "diligence", "management"], ["underhandedness", "greediness", "unethical"]),
(["hard work", "productivity", "routine"], ["laziness", "obsession with work", "burnout"]),
(["nurturing", "practical", "abundance"], ["financial dependence", "smothering", "insecurity"]),
(["abundance", "prosperity", "security"], ["greed", "indulgence", "sensual obsession"]),
],
}
def seed_tarot_cards(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
# Major Arcana
for number, name, slug, upright, reversed_ in MAJOR_ARCANA:
TarotCard.objects.create(
name=name,
arcana="MAJOR",
suit=None,
number=number,
slug=slug,
keywords_upright=upright,
keywords_reversed=reversed_,
)
# Minor Arcana
for suit_code, suit_slug in MINOR_SUITS:
for number, rank_name, rank_slug in MINOR_NAMES:
upright, reversed_ = MINOR_KEYWORDS[suit_code][number - 1]
TarotCard.objects.create(
name=f"{rank_name} of {suit_code.capitalize()}",
arcana="MINOR",
suit=suit_code,
number=number,
slug=f"{rank_slug}-of-{suit_slug}",
keywords_upright=upright,
keywords_reversed=reversed_,
)
def unseed_tarot_cards(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
TarotCard.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
("epic", "0007_tarotcard_tarotdeck"),
]
operations = [
migrations.RunPython(seed_tarot_cards, reverse_code=unseed_tarot_cards),
]

View File

@@ -0,0 +1,68 @@
# Generated by Django 6.0 on 2026-03-25 00:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0008_seed_tarot_cards'),
]
operations = [
migrations.CreateModel(
name='DeckVariant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(unique=True)),
('card_count', models.IntegerField()),
('description', models.TextField(blank=True)),
('is_default', models.BooleanField(default=False)),
],
),
migrations.AlterModelOptions(
name='tarotcard',
options={'ordering': ['deck_variant', 'arcana', 'suit', 'number']},
),
migrations.AddField(
model_name='tarotcard',
name='correspondence',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='tarotcard',
name='group',
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterField(
model_name='tarotcard',
name='name',
field=models.CharField(max_length=200),
),
migrations.AlterField(
model_name='tarotcard',
name='slug',
field=models.SlugField(max_length=120),
),
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('COINS', 'Coins')], max_length=10, null=True),
),
migrations.AddField(
model_name='tarotcard',
name='deck_variant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='epic.deckvariant'),
),
migrations.AddField(
model_name='tarotdeck',
name='deck_variant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_decks', to='epic.deckvariant'),
),
migrations.AlterUniqueTogether(
name='tarotcard',
unique_together={('deck_variant', 'slug')},
),
]

View File

@@ -0,0 +1,202 @@
"""
Data migration:
1. Create DeckVariant records (Fiorentine Minchiate + Earthman).
2. Backfill the 78 existing TarotCards → Fiorentine Minchiate.
3. Seed all 108 Earthman cards (52 major + 56 minor).
"""
from django.db import migrations
# ── Earthman Major Arcana (52 cards, numbers 051) ──────────────────────────
# (name, slug, group, correspondence)
EARTHMAN_MAJOR = [
# ── The Schiz ──────────────────────────────────────────────────────────
(0, "The Schiz", "the-schiz", "", "The Fool / Il Matto"),
# ── The Popes ──────────────────────────────────────────────────────────
(1, "Pope I: President", "pope-i-president", "The Popes", "The Magician / Il Bagatto"),
(2, "Pope II: Tsar", "pope-ii-tsar", "The Popes", "The Popess / La Papessa"),
(3, "Pope III: Chairman", "pope-iii-chairman", "The Popes", "The Empress / L'Imperatrice"),
(4, "Pope IV: Emperor", "pope-iv-emperor", "The Popes", "The Emperor / L'Imperatore"),
(5, "Pope V: Chancellor", "pope-v-chancellor", "The Popes", "The Pope / Il Papa"),
# ── The Virtues, Implicit (cardinal / acquired) ────────────────────────
(6, "Virtue VI: Controlled Folly", "virtue-vi-controlled-folly", "The Virtues, Implicit", "Fortitude / La Fortezza"),
(7, "Virtue VII: Not-Doing", "virtue-vii-not-doing", "The Virtues, Implicit", "Justice / La Giustizia"),
(8, "Virtue VIII: Losing Self-Importance","virtue-viii-losing-self-importance","The Virtues, Implicit", "Temperance / La Temperanza"),
(9, "Virtue IX: Erasing Personal History","virtue-ix-erasing-personal-history","The Virtues, Implicit", "Prudence / La Prudenza"),
# ── Wheel ──────────────────────────────────────────────────────────────
(10, "Wheel of Fortune", "wheel-of-fortune-em", "", "La Ruota della Fortuna"),
# ── Solo cards ─────────────────────────────────────────────────────────
(11, "The Junkboat", "the-junkboat", "", "The Chariot / Il Carro"),
(12, "The Junkman", "the-junkman", "", "The Hanged Man / L'Appeso"),
(13, "Death", "death-em", "", "La Morte"),
(14, "The Traitor", "the-traitor", "", "The Devil / Il Diavolo"),
(15, "Disco Inferno", "disco-inferno", "", "The Tower / La Torre"),
(16, "Torre Terrestre", "torre-terrestre", "", "Purgatorio"),
(17, "Fantasia Celestia", "fantasia-celestia", "", "Paradiso"),
# ── The Virtues, Explicit (theological / infused) ─────────────────────
(18, "Virtue XVIII: Stalking", "virtue-xviii-stalking", "The Virtues, Explicit", "Love / Charity / La Carità"),
(19, "Virtue XIX: Intent", "virtue-xix-intent", "The Virtues, Explicit", "Hope / La Speranza"),
(20, "Virtue XX: Dreaming", "virtue-xx-dreaming", "The Virtues, Explicit", "Faith / La Fede"),
# ── The Elements, Classical ────────────────────────────────────────────
(21, "Element XXI: Fire", "element-xxi-fire", "The Elements, Classical", "Ardor [Ar]"),
(22, "Element XXII: Earth", "element-xxii-earth", "The Elements, Classical", "Ossum [Om]"),
(23, "Element XXIII: Air", "element-xxiii-air", "The Elements, Classical", "Pneuma [Pn]"),
(24, "Element XXIV: Water", "element-xxiv-water", "The Elements, Classical", "Humor [Hm]"),
# ── The Zodiac ─────────────────────────────────────────────────────────
(25, "Zodiac XXV: Aries", "zodiac-xxv-aries", "The Zodiac", "The Ram"),
(26, "Zodiac XXVI: Taurus", "zodiac-xxvi-taurus", "The Zodiac", "The Bull"),
(27, "Zodiac XXVII: Gemini", "zodiac-xxvii-gemini", "The Zodiac", "The Twins"),
(28, "Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer", "The Zodiac", "The Crab"),
(29, "Zodiac XXIX: Leo", "zodiac-xxix-leo", "The Zodiac", "The Lion"),
(30, "Zodiac XXX: Virgo", "zodiac-xxx-virgo", "The Zodiac", "The Maiden"),
(31, "Zodiac XXXI: Libra", "zodiac-xxxi-libra", "The Zodiac", "The Scales"),
(32, "Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio", "The Zodiac", "The Scorpion"),
(33, "Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius", "The Zodiac", "The Archer"),
(34, "Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn", "The Zodiac", "The Sea-Goat"),
(35, "Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius", "The Zodiac", "The Water-Bearer"),
(36, "Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces", "The Zodiac", "The Fish"),
# ── The Elements, Absolute ─────────────────────────────────────────────
(37, "Element XXXVII: Time", "element-xxxvii-time", "The Elements, Absolute", "Tempo [Tp]"),
(38, "Element XXXVIII: Space", "element-xxxviii-space", "The Elements, Absolute", "Nexus [Nx]"),
# ── The Wanderers ──────────────────────────────────────────────────────
(39, "Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar", "The Wanderers", "The Star / Le Stelle"),
(40, "Wanderer XL: The Antichthon", "wanderer-xl-antichthon", "The Wanderers", "The Moon / La Luna"),
(41, "Wanderer XLI: The Corestar", "wanderer-xli-corestar", "The Wanderers", "The Sun / Il Sole"),
(42, "Wanderer XLII: Mercury", "wanderer-xlii-mercury", "The Wanderers", "Mercurio"),
(43, "Wanderer XLIII: Venus", "wanderer-xliii-venus", "The Wanderers", "Venere"),
(44, "Wanderer XLIV: Mars", "wanderer-xliv-mars", "The Wanderers", "Marte"),
(45, "Wanderer XLV: Jupiter", "wanderer-xlv-jupiter", "The Wanderers", "Giove"),
(46, "Wanderer XLVI: Saturn", "wanderer-xlvi-saturn", "The Wanderers", "Saturno"),
(47, "Wanderer XLVII: Uranus", "wanderer-xlvii-uranus", "The Wanderers", "Urano"),
(48, "Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune", "The Wanderers", "Nettuno"),
(49, "Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades", "The Wanderers", "The Binary / Plutone-Proserpina"),
# ── Finale ─────────────────────────────────────────────────────────────
(50, "The Eagle", "the-eagle", "", "Judgement / L'Angelo"),
(51, "Divine Calculus", "divine-calculus", "", "The World / Il Mondo"),
]
# ── Earthman Minor Arcana ────────────────────────────────────────────────────
# 4 suits × 14 cards. Suits: WANDS / CUPS / SWORDS / COINS
# Court cards: Jack (11) / Cavalier (12) / Queen (13) / King (14)
EARTHMAN_SUITS = [
("WANDS", "wands", "Ardor [Ar] — Fire"),
("CUPS", "cups", "Humor [Hm] — Water"),
("SWORDS","swords","Pneuma [Pn] — Air"),
("COINS", "coins", "Ossum [Om] — Stone"),
]
EARTHMAN_RANKS = [
(1, "Ace", "ace"),
(2, "2", "two"),
(3, "3", "three"),
(4, "4", "four"),
(5, "5", "five"),
(6, "6", "six"),
(7, "7", "seven"),
(8, "8", "eight"),
(9, "9", "nine"),
(10, "10", "ten"),
(11, "Jack", "jack"),
(12, "Cavalier", "cavalier"),
(13, "Queen", "queen"),
(14, "King", "king"),
]
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
# ── 1. Create DeckVariant records ────────────────────────────────────
fiorentine = DeckVariant.objects.create(
name="Fiorentine Minchiate",
slug="fiorentine-minchiate",
card_count=78,
description="Standard 78-card Minchiate deck. Alt / lite play mode.",
is_default=False,
)
earthman = DeckVariant.objects.create(
name="Earthman Deck",
slug="earthman",
card_count=108,
description=(
"Primary 108-card Earthman deck. "
"52 Major Arcana (The Schiz through Divine Calculus) "
"+ 56 Minor Arcana across Wands, Cups, Swords, Coins."
),
is_default=True,
)
# ── 2. Backfill existing 78 Fiorentine cards ─────────────────────────
TarotCard.objects.filter(deck_variant__isnull=True).update(
deck_variant=fiorentine
)
# ── 3. Seed Earthman Major Arcana ────────────────────────────────────
for number, name, slug, group, correspondence in EARTHMAN_MAJOR:
TarotCard.objects.create(
deck_variant=earthman,
name=name,
arcana="MAJOR",
suit=None,
number=number,
slug=slug,
group=group,
correspondence=correspondence,
keywords_upright=[],
keywords_reversed=[],
)
# ── 4. Seed Earthman Minor Arcana ────────────────────────────────────
for suit_code, suit_slug, _element in EARTHMAN_SUITS:
for number, rank_name, rank_slug in EARTHMAN_RANKS:
name = f"{rank_name} of {suit_code.capitalize()}"
slug = f"{rank_slug}-of-{suit_slug}-em"
TarotCard.objects.create(
deck_variant=earthman,
name=name,
arcana="MINOR",
suit=suit_code,
number=number,
slug=slug,
group="",
correspondence="",
keywords_upright=[],
keywords_reversed=[],
)
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
# Remove Earthman cards and clear FK from Fiorentine cards
earthman = DeckVariant.objects.filter(slug="earthman").first()
if earthman:
TarotCard.objects.filter(deck_variant=earthman).delete()
fiorentine = DeckVariant.objects.filter(slug="fiorentine-minchiate").first()
if fiorentine:
TarotCard.objects.filter(deck_variant=fiorentine).update(deck_variant=None)
DeckVariant.objects.filter(slug__in=["earthman", "fiorentine-minchiate"]).delete()
class Migration(migrations.Migration):
dependencies = [
("epic", "0009_deckvariant_alter_tarotcard_options_and_more"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,82 @@
"""
Data migration: rename Earthman court cards at positions 11 and 12.
Old naming (from 0010): Jack (11) / Cavalier (12)
New naming: Maid (11) / Jack (12)
Must rename 11 → Maid first so the "jack-of-*-em" slugs are free
before the 12s claim them.
"""
from django.db import migrations
SUITS = ["Wands", "Cups", "Swords", "Coins"]
def rename_court_cards(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Step 1: Jack (11) → Maid — frees up jack-of-*-em slugs
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=11, slug=f"jack-of-{suit_slug}-em"
).update(
name=f"Maid of {suit}",
slug=f"maid-of-{suit_slug}-em",
)
# Step 2: Cavalier (12) → Jack — takes the now-free jack-of-*-em slugs
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=12, slug=f"cavalier-of-{suit_slug}-em"
).update(
name=f"Jack of {suit}",
slug=f"jack-of-{suit_slug}-em",
)
def reverse_court_cards(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Step 1: Jack (12) → Cavalier — frees up jack-of-*-em slugs
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=12, slug=f"jack-of-{suit_slug}-em"
).update(
name=f"Cavalier of {suit}",
slug=f"cavalier-of-{suit_slug}-em",
)
# Step 2: Maid (11) → Jack
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=11, slug=f"maid-of-{suit_slug}-em"
).update(
name=f"Jack of {suit}",
slug=f"jack-of-{suit_slug}-em",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0010_seed_deck_variants_and_earthman"),
]
operations = [
migrations.RunPython(rename_court_cards, reverse_code=reverse_court_cards),
]

View File

@@ -0,0 +1,162 @@
"""
Data migration:
1. Rename grouped Earthman major arcana to use group-relative ordinals
(e.g. "Virtue VI: Controlled Folly""Implicit Virtue 1: Controlled Folly").
2. Spell out Earthman minor arcana pip names 210
(e.g. "2 of Wands""Two of Wands").
Corner ranks (Roman numerals of absolute card number) are a property on the model
and are unchanged — this only affects the stored name / slug fields.
"""
from django.db import migrations
# ── Major arcana: (new_name, new_slug) keyed by card number ─────────────────
MAJOR_RENAMES = {
# Implicit Virtues (cards 69)
6: ("Implicit Virtue 1: Controlled Folly", "implicit-virtue-1-controlled-folly"),
7: ("Implicit Virtue 2: Not-Doing", "implicit-virtue-2-not-doing"),
8: ("Implicit Virtue 3: Losing Self-Importance", "implicit-virtue-3-losing-self-importance"),
9: ("Implicit Virtue 4: Erasing Personal History", "implicit-virtue-4-erasing-personal-history"),
# Explicit Virtues (cards 1820)
18: ("Explicit Virtue 1: Stalking", "explicit-virtue-1-stalking"),
19: ("Explicit Virtue 2: Intent", "explicit-virtue-2-intent"),
20: ("Explicit Virtue 3: Dreaming", "explicit-virtue-3-dreaming"),
# Classical Elements (cards 2124)
21: ("Classical Element 1: Fire", "classical-element-1-fire"),
22: ("Classical Element 2: Earth", "classical-element-2-earth"),
23: ("Classical Element 3: Air", "classical-element-3-air"),
24: ("Classical Element 4: Water", "classical-element-4-water"),
# Zodiac (cards 2536)
25: ("Zodiac 1: Aries", "zodiac-1-aries"),
26: ("Zodiac 2: Taurus", "zodiac-2-taurus"),
27: ("Zodiac 3: Gemini", "zodiac-3-gemini"),
28: ("Zodiac 4: Cancer", "zodiac-4-cancer"),
29: ("Zodiac 5: Leo", "zodiac-5-leo"),
30: ("Zodiac 6: Virgo", "zodiac-6-virgo"),
31: ("Zodiac 7: Libra", "zodiac-7-libra"),
32: ("Zodiac 8: Scorpio", "zodiac-8-scorpio"),
33: ("Zodiac 9: Sagittarius", "zodiac-9-sagittarius"),
34: ("Zodiac 10: Capricorn", "zodiac-10-capricorn"),
35: ("Zodiac 11: Aquarius", "zodiac-11-aquarius"),
36: ("Zodiac 12: Pisces", "zodiac-12-pisces"),
# Absolute Elements (cards 3738)
37: ("Absolute Element 1: Time", "absolute-element-1-time"),
38: ("Absolute Element 2: Space", "absolute-element-2-space"),
# Wanderers (cards 3949)
39: ("Wanderer 1: The Polestar", "wanderer-1-polestar"),
40: ("Wanderer 2: The Antichthon", "wanderer-2-antichthon"),
41: ("Wanderer 3: The Corestar", "wanderer-3-corestar"),
42: ("Wanderer 4: Mercury", "wanderer-4-mercury"),
43: ("Wanderer 5: Venus", "wanderer-5-venus"),
44: ("Wanderer 6: Mars", "wanderer-6-mars"),
45: ("Wanderer 7: Jupiter", "wanderer-7-jupiter"),
46: ("Wanderer 8: Saturn", "wanderer-8-saturn"),
47: ("Wanderer 9: Uranus", "wanderer-9-uranus"),
48: ("Wanderer 10: Neptune", "wanderer-10-neptune"),
49: ("Wanderer 11: The King & Queen of Hades", "wanderer-11-king-queen-hades"),
}
# Original (name, slug) pairs for reversal
MAJOR_ORIGINALS = {
6: ("Virtue VI: Controlled Folly", "virtue-vi-controlled-folly"),
7: ("Virtue VII: Not-Doing", "virtue-vii-not-doing"),
8: ("Virtue VIII: Losing Self-Importance", "virtue-viii-losing-self-importance"),
9: ("Virtue IX: Erasing Personal History", "virtue-ix-erasing-personal-history"),
18: ("Virtue XVIII: Stalking", "virtue-xviii-stalking"),
19: ("Virtue XIX: Intent", "virtue-xix-intent"),
20: ("Virtue XX: Dreaming", "virtue-xx-dreaming"),
21: ("Element XXI: Fire", "element-xxi-fire"),
22: ("Element XXII: Earth", "element-xxii-earth"),
23: ("Element XXIII: Air", "element-xxiii-air"),
24: ("Element XXIV: Water", "element-xxiv-water"),
25: ("Zodiac XXV: Aries", "zodiac-xxv-aries"),
26: ("Zodiac XXVI: Taurus", "zodiac-xxvi-taurus"),
27: ("Zodiac XXVII: Gemini", "zodiac-xxvii-gemini"),
28: ("Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer"),
29: ("Zodiac XXIX: Leo", "zodiac-xxix-leo"),
30: ("Zodiac XXX: Virgo", "zodiac-xxx-virgo"),
31: ("Zodiac XXXI: Libra", "zodiac-xxxi-libra"),
32: ("Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio"),
33: ("Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius"),
34: ("Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn"),
35: ("Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius"),
36: ("Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces"),
37: ("Element XXXVII: Time", "element-xxxvii-time"),
38: ("Element XXXVIII: Space", "element-xxxviii-space"),
39: ("Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar"),
40: ("Wanderer XL: The Antichthon", "wanderer-xl-antichthon"),
41: ("Wanderer XLI: The Corestar", "wanderer-xli-corestar"),
42: ("Wanderer XLII: Mercury", "wanderer-xlii-mercury"),
43: ("Wanderer XLIII: Venus", "wanderer-xliii-venus"),
44: ("Wanderer XLIV: Mars", "wanderer-xliv-mars"),
45: ("Wanderer XLV: Jupiter", "wanderer-xlv-jupiter"),
46: ("Wanderer XLVI: Saturn", "wanderer-xlvi-saturn"),
47: ("Wanderer XLVII: Uranus", "wanderer-xlvii-uranus"),
48: ("Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune"),
49: ("Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades"),
}
# Pip number → spelled-out word (slugs already use the word form, only name changes)
PIP_SPELLINGS = {
2: "Two", 3: "Three", 4: "Four", 5: "Five",
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten",
}
SUITS = ["WANDS", "CUPS", "SWORDS", "COINS"]
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# 1. Rename grouped major arcana to group-relative ordinals
for number, (new_name, new_slug) in MAJOR_RENAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
# 2. Spell out pip names 210
for number, word in PIP_SPELLINGS.items():
for suit in SUITS:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
).update(name=f"{word} of {suit.capitalize()}")
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# 1. Restore original major arcana names
for number, (old_name, old_slug) in MAJOR_ORIGINALS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
# 2. Restore numeric pip names (slugs unchanged)
for number, _word in PIP_SPELLINGS.items():
for suit in SUITS:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
).update(name=f"{number} of {suit.capitalize()}")
class Migration(migrations.Migration):
dependencies = [
("epic", "0011_rename_earthman_court_cards"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,55 @@
"""
Data migration: rename Earthman 4th-suit cards from COINS → PENTACLES.
Updates:
- suit field: "COINS""PENTACLES"
- name: "X of Coins""X of Pentacles"
- slug: "x-of-coins-em""x-of-pentacles-em"
"""
from django.db import migrations
def coins_to_pentacles(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
cards = TarotCard.objects.filter(deck_variant=earthman, suit="COINS")
for card in cards:
card.suit = "PENTACLES"
card.name = card.name.replace(" of Coins", " of Pentacles")
card.slug = card.slug.replace("-of-coins-em", "-of-pentacles-em")
card.save(update_fields=["suit", "name", "slug"])
def pentacles_to_coins(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Only reverse cards that came from Earthman (identified by -em slug suffix)
cards = TarotCard.objects.filter(
deck_variant=earthman, suit="PENTACLES", slug__endswith="-em"
)
for card in cards:
card.suit = "COINS"
card.name = card.name.replace(" of Pentacles", " of Coins")
card.slug = card.slug.replace("-of-pentacles-em", "-of-coins-em")
card.save(update_fields=["suit", "name", "slug"])
class Migration(migrations.Migration):
dependencies = [
("epic", "0012_rename_earthman_major_groups_and_pip_spellings"),
]
operations = [
migrations.RunPython(coins_to_pentacles, reverse_code=pentacles_to_coins),
]

View File

@@ -0,0 +1,65 @@
"""
Data migration: rename the five Pope cards to use Arabic group-relative ordinals,
matching the convention set for other grouped major arcana.
"Pope I: President""Pope 1: President"
"Pope II: Tsar""Pope 2: Tsar"
etc.
"""
from django.db import migrations
POPE_RENAMES = {
1: ("Pope 1: President", "pope-1-president"),
2: ("Pope 2: Tsar", "pope-2-tsar"),
3: ("Pope 3: Chairman", "pope-3-chairman"),
4: ("Pope 4: Emperor", "pope-4-emperor"),
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
}
POPE_ORIGINALS = {
1: ("Pope I: President", "pope-i-president"),
2: ("Pope II: Tsar", "pope-ii-tsar"),
3: ("Pope III: Chairman", "pope-iii-chairman"),
4: ("Pope IV: Emperor", "pope-iv-emperor"),
5: ("Pope V: Chancellor", "pope-v-chancellor"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in POPE_RENAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in POPE_ORIGINALS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0013_earthman_coins_to_pentacles"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,42 @@
"""
Data migration: rename Earthman card 22 from "Classical Element 2: Earth"
to "Classical Element 2: Stone" (Stone = Ossum, the Earthman name for Earth).
"""
from django.db import migrations
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=22
).update(name="Classical Element 2: Stone", slug="classical-element-2-stone")
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=22
).update(name="Classical Element 2: Earth", slug="classical-element-2-earth")
class Migration(migrations.Migration):
dependencies = [
("epic", "0014_rename_earthman_popes_arabic_ordinals"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,63 @@
"""
Data migration: reorder the five Pope cards.
New assignment (card number → title):
1 → Chancellor 2 → President 3 → Tsar 4 → Chairman 5 → Emperor
"""
from django.db import migrations
POPE_RENAMES = {
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
2: ("Pope 2: President", "pope-2-president"),
3: ("Pope 3: Tsar", "pope-3-tsar"),
4: ("Pope 4: Chairman", "pope-4-chairman"),
5: ("Pope 5: Emperor", "pope-5-emperor"),
}
POPE_ORIGINALS = {
1: ("Pope 1: President", "pope-1-president"),
2: ("Pope 2: Tsar", "pope-2-tsar"),
3: ("Pope 3: Chairman", "pope-3-chairman"),
4: ("Pope 4: Emperor", "pope-4-emperor"),
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in POPE_RENAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in POPE_ORIGINALS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0015_rename_classical_element_earth_to_stone"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0 on 2026-03-25 05:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0016_reorder_earthman_popes'),
]
operations = [
migrations.AddField(
model_name='tableseat',
name='significator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='significator_seats', to='epic.tarotcard'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-04-01 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0017_tableseat_significator_fk'),
]
operations = [
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,70 @@
"""
Data migration: rename The Schiz (card 0) and the five Pope cards (cards 15)
in the Earthman deck.
0: "The Schiz""The Nomad"
1: "Pope 1: Chancellor""Pope 1: The Schizo"
2: "Pope 2: President""Pope 2: The Despot"
3: "Pope 3: Tsar""Pope 3: The Capitalist"
4: "Pope 4: Chairman""Pope 4: The Fascist"
5: "Pope 5: Emperor""Pope 5: The War Machine"
"""
from django.db import migrations
NEW_NAMES = {
0: ("The Nomad", "the-nomad"),
1: ("Pope 1: The Schizo", "pope-1-the-schizo"),
2: ("Pope 2: The Despot", "pope-2-the-despot"),
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
5: ("Pope 5: The War Machine","pope-5-the-war-machine"),
}
OLD_NAMES = {
0: ("The Schiz", "the-schiz"),
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
2: ("Pope 2: President", "pope-2-president"),
3: ("Pope 3: Tsar", "pope-3-tsar"),
4: ("Pope 4: Chairman", "pope-4-chairman"),
5: ("Pope 5: Emperor", "pope-5-emperor"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in NEW_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in OLD_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0018_alter_tarotcard_suit"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,63 @@
"""
Data migration: rename Pope cards 25 in the Earthman deck.
2: "Pope 2: The Despot""Pope 2: The Occultist"
3: "Pope 3: The Capitalist""Pope 3: The Despot"
4: "Pope 4: The Fascist""Pope 4: The Capitalist"
5: "Pope 5: The War Machine""Pope 5: The Fascist"
"""
from django.db import migrations
NEW_NAMES = {
2: ("Pope 2: The Occultist", "pope-2-the-occultist"),
3: ("Pope 3: The Despot", "pope-3-the-despot"),
4: ("Pope 4: The Capitalist","pope-4-the-capitalist"),
5: ("Pope 5: The Fascist", "pope-5-the-fascist"),
}
OLD_NAMES = {
2: ("Pope 2: The Despot", "pope-2-the-despot"),
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
5: ("Pope 5: The War Machine", "pope-5-the-war-machine"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in NEW_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in OLD_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0019_rename_earthman_schiz_and_popes"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,56 @@
"""
Data migration: rename/update six Earthman Major Arcana cards.
13 name: "Death""King Death & the Cosmic Tree"
14 name: "The Traitor""The Great Hunt"
15 correspondence: "The Tower / La Torre""The House of the Devil / Inferno"
16 correspondence: "Purgatorio""The Tower / La Torre / Purgatorio"
50 name/slug: "The Eagle""The Mould of Man"
51 name/slug: "Divine Calculus""The Eagle"
"""
from django.db import migrations
FORWARD = {
13: dict(name="King Death & the Cosmic Tree", slug="king-death-and-the-cosmic-tree"),
14: dict(name="The Great Hunt", slug="the-great-hunt"),
15: dict(correspondence="The House of the Devil / Inferno"),
16: dict(correspondence="The Tower / La Torre / Purgatorio"),
50: dict(name="The Mould of Man", slug="the-mould-of-man"),
51: dict(name="The Eagle", slug="the-eagle"),
}
REVERSE = {
13: dict(name="Death", slug="death-em"),
14: dict(name="The Traitor", slug="the-traitor"),
15: dict(correspondence="The Tower / La Torre"),
16: dict(correspondence="Purgatorio"),
50: dict(name="The Eagle", slug="the-eagle"),
51: dict(name="Divine Calculus",slug="divine-calculus"),
}
def apply(changes):
def fn(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Process in sorted order so card 50 vacates "the-eagle" slug before card 51 claims it.
for number in sorted(changes):
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(**changes[number])
return fn
class Migration(migrations.Migration):
dependencies = [
("epic", "0020_rename_earthman_pope_cards_2_5"),
]
operations = [
migrations.RunPython(apply(FORWARD), reverse_code=apply(REVERSE)),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2026-04-06 00:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0021_rename_earthman_major_arcana_batch_2'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SigReservation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(max_length=2)),
('polarity', models.CharField(choices=[('levity', 'Levity'), ('gravity', 'Gravity')], max_length=7)),
('reserved_at', models.DateTimeField(auto_now_add=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.tarotcard')),
('gamer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.room')),
],
options={
'constraints': [models.UniqueConstraint(fields=('room', 'gamer'), name='one_sig_reservation_per_gamer_per_room'), models.UniqueConstraint(fields=('room', 'card', 'polarity'), name='one_reservation_per_card_per_polarity_per_room')],
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0 on 2026-04-06 02:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0022_sig_reservation'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='icon',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AlterField(
model_name='tarotcard',
name='arcana',
field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6),
),
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,46 @@
"""
Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS.
Updates for every Earthman card where suit="PENTACLES":
- suit: "PENTACLES""CROWNS"
- name: " of Pentacles"" of Crowns"
- slug: "pentacles""crowns"
"""
from django.db import migrations
def pentacles_to_crowns(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"):
card.suit = "CROWNS"
card.name = card.name.replace(" of Pentacles", " of Crowns")
card.slug = card.slug.replace("pentacles", "crowns")
card.save(update_fields=["suit", "name", "slug"])
def crowns_to_pentacles(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"):
card.suit = "PENTACLES"
card.name = card.name.replace(" of Crowns", " of Pentacles")
card.slug = card.slug.replace("crowns", "pentacles")
card.save(update_fields=["suit", "name", "slug"])
class Migration(migrations.Migration):
dependencies = [
("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"),
]
operations = [
migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles),
]

View File

@@ -0,0 +1,62 @@
"""
Data migration: Earthman deck — court cards and major arcana icons.
1. Court cards (numbers 1114, all suits): arcana "MINOR""MIDDLE"
2. Major arcana icons (stored in TarotCard.icon):
0 (Nomad) → fa-hat-cowboy-side
1 (Schizo) → fa-hat-wizard
251 (rest) → fa-hand-dots
"""
from django.db import migrations
MAJOR_ICONS = {
0: "fa-hat-cowboy-side",
1: "fa-hat-wizard",
}
DEFAULT_MAJOR_ICON = "fa-hand-dots"
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Court cards → MIDDLE
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14]
).update(arcana="MIDDLE")
# Major arcana icons
for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"):
card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON)
card.save(update_fields=["icon"])
def backward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14]
).update(arcana="MINOR")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR"
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0024_earthman_pentacles_to_crowns"),
]
operations = [
migrations.RunPython(forward, reverse_code=backward),
]

View File

@@ -0,0 +1,154 @@
"""
Data migration — Earthman deck:
1. Rename three suit codes (and card names) for Earthman cards:
WANDS → BRANDS (Wands → Brands)
CUPS → GRAILS (Cups → Grails)
SWORDS → BLADES (Swords → Blades)
CROWNS stays CROWNS.
2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate
deck to corresponding Earthman cards:
• Major: explicit number-to-number map based on card correspondences.
• Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS,
BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart
stay with empty keyword lists.
"""
from django.db import migrations
# ── 1. Suit rename map ────────────────────────────────────────────────────────
SUIT_RENAMES = {
"WANDS": "BRANDS",
"CUPS": "GRAILS",
"SWORDS": "BLADES",
}
# ── 2. Major arcana: Earthman number → Fiorentine number ─────────────────────
# Cards without a Fiorentine counterpart are omitted (keywords stay empty).
MAJOR_KEYWORD_MAP = {
0: 0, # The Schiz → The Fool
1: 1, # Pope I (President) → The Magician
2: 2, # Pope II (Tsar) → The High Priestess
3: 3, # Pope III (Chairman) → The Empress
4: 4, # Pope IV (Emperor) → The Emperor
5: 5, # Pope V (Chancellor) → The Hierophant
6: 8, # Virtue VI (Controlled Folly) → Strength
7: 11, # Virtue VII (Not-Doing) → Justice
8: 14, # Virtue VIII (Losing Self-Importance) → Temperance
# 9: Prudence — no Fiorentine equivalent
10: 10, # Wheel of Fortune → Wheel of Fortune
11: 7, # The Junkboat → The Chariot
12: 12, # The Junkman → The Hanged Man
13: 13, # Death → Death
14: 15, # The Traitor → The Devil
15: 16, # Disco Inferno → The Tower
# 16: Torre Terrestre (Purgatory) — no equivalent
# 17: Fantasia Celestia (Paradise) — no equivalent
18: 6, # Virtue XVIII (Stalking) → The Lovers
# 19: Virtue XIX (Intent / Hope) — no equivalent
# 20: Virtue XX (Dreaming / Faith)— no equivalent
# 2138: Classical Elements + Zodiac — no equivalents
39: 17, # Wanderer XXXIX (Polestar) → The Star
40: 18, # Wanderer XL (Antichthon) → The Moon
41: 19, # Wanderer XLI (Corestar) → The Sun
# 4249: Planets + The Binary — no equivalents
50: 20, # The Eagle → Judgement
51: 21, # Divine Calculus → The World
}
# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ───────────────────
MINOR_SUIT_MAP = {
"BRANDS": "WANDS",
"GRAILS": "CUPS",
"BLADES": "SWORDS",
"CROWNS": "PENTACLES",
}
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
except DeckVariant.DoesNotExist:
return # decks not seeded — nothing to do
# ── Step 1: rename Earthman suit codes + card names ───────────────────────
for old_suit, new_suit in SUIT_RENAMES.items():
old_display = old_suit.capitalize() # e.g. "Wands"
new_display = new_suit.capitalize() # e.g. "Brands"
cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit)
for card in cards:
card.name = card.name.replace(f" of {old_display}", f" of {new_display}")
card.suit = new_suit
card.save()
# ── Step 2: copy major arcana keywords ───────────────────────────────────
fio_major = {
card.number: card
for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR")
}
for em_num, fio_num in MAJOR_KEYWORD_MAP.items():
fio_card = fio_major.get(fio_num)
if not fio_card:
continue
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=em_num
).update(
keywords_upright=fio_card.keywords_upright,
keywords_reversed=fio_card.keywords_reversed,
)
# ── Step 3: copy minor/middle arcana keywords ─────────────────────────────
for em_suit, fio_suit in MINOR_SUIT_MAP.items():
fio_by_number = {
card.number: card
for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit)
}
for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit):
fio_card = fio_by_number.get(em_card.number)
if fio_card:
em_card.keywords_upright = fio_card.keywords_upright
em_card.keywords_reversed = fio_card.keywords_reversed
em_card.save()
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Reverse suit renames
reverse_renames = {new: old for old, new in SUIT_RENAMES.items()}
for new_suit, old_suit in reverse_renames.items():
new_display = new_suit.capitalize()
old_display = old_suit.capitalize()
cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit)
for card in cards:
card.name = card.name.replace(f" of {new_display}", f" of {old_display}")
card.suit = old_suit
card.save()
# Clear all Earthman keywords
TarotCard.objects.filter(deck_variant=earthman).update(
keywords_upright=[],
keywords_reversed=[],
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0025_earthman_middle_arcana_and_major_icons"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,65 @@
"""
Schema + data migration:
1. Add `cautions` JSONField (list, default=[]) to TarotCard.
2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions.
All other cards default to [] — the UI shows a placeholder when empty.
"""
from django.db import migrations, models
SCHIZO_CAUTIONS = [
'This card will reverse into <span class="card-ref">The Pervert</span> when it'
' comes under dominion of <span class="card-ref">The Occultist</span>, which in turn'
' reverses into <span class="card-ref">Pestilence</span>.',
'This card will reverse into <span class="card-ref">The Paranoiac</span> when it'
' comes under dominion of <span class="card-ref">The Despot</span>, which in turn'
' reverses into <span class="card-ref">War</span>.',
'This card will reverse into <span class="card-ref">The Neurotic</span> when it'
' comes under dominion of <span class="card-ref">The Capitalist</span>, which in turn'
' reverses into <span class="card-ref">Famine</span>.',
'This card will reverse into <span class="card-ref">The Suicidal</span> when it'
' comes under dominion of <span class="card-ref">The Fascist</span>, which in turn'
' reverses into <span class="card-ref">Death</span>.',
]
def seed_schizo_cautions(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1
).update(cautions=SCHIZO_CAUTIONS)
def clear_schizo_cautions(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1
).update(cautions=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0026_earthman_suit_renames_and_keywords"),
]
operations = [
migrations.AddField(
model_name="tarotcard",
name="cautions",
field=models.JSONField(default=list),
),
migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions),
]

Some files were not shown because too many files have changed in this diff Show More