Files
python-tdd/CLAUDE.md
Disco DeDisco 5413e63585 billboard Most Recent Scroll: fix SQLite NULL drop on SIG_READY exclude; pronouns flow FT; Blades middle reversal Nervous → Fickle — TDD
- billboard/views.py _billboard_context: `.exclude(verb=SIG_READY, data__retracted=True)` was silently dropping every SIG_READY event whose data had no `retracted` key — `WHERE NOT (NULL AND verb='sig_ready')` evaluates to NULL via JSON_EXTRACT, which the SQL engine treats as "row not satisfying WHERE", so the row was excluded. Fix: pull a 100-row buffer w. only the SIG_UNREADY exclude at the SQL level, then post-filter retracted SIG_READY in Python before slicing to 36; PostgreSQL handles the lookup correctly so this is a SQLite-only manifestation that explained intermittent "No events yet" in Most Recent Scroll
- CLAUDE.md gotchas: new entry warning that `.exclude(data__key=value)` / `.filter(data__key=value)` on SQLite JSONField bites on missing keys; if the predicate must require key existence, post-filter in Python
- functional_tests/test_game_kit.py PronounsAppletFlowTest: end-to-end profile-wide pronoun flip — start on per-room billscroll seeing "their" cognates, navigate to Game Kit, click bawlmorese card, assert guard portal active w. "yo/yo/yos" preview, click OK, navigate to billboard + see Most Recent Scroll re-rendered w. "yos", navigate back to billscroll + see same flip; covers the whole render-time-pronoun-resolution path on real DOM
- epic/0008_blades_reversal_fickle.py: rename Middle Arcana Blades reversal_qualifier "Nervous" → "Fickle" (RunPython forward+reverse on arcana=MIDDLE, suit=BLADES, number ∈ {11,12,13,14}); SigSelectSpec.js hardcoded "Nervous" updated to "Fickle" + collected static

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:27:17 -04:00

9.1 KiB
Raw Blame History

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 for browser automation.
See .claude/skills/claudezilla-browser/SKILL.md for tool list, startup protocol, and setup reference.

STARTUP RULE: Call mcp__claudezilla__firefox_diagnose at the start of every conversation before any browser tool. If tools aren't listed in a session, open a new Claude Code conversation (MCP servers load at startup only).

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

# 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 (exclude channels)
python src/manage.py test src/apps --exclude-tag=channels

# Functional tests
python src/manage.py test src/functional_tests

See .claude/skills/TDD/SKILL.md for the full TDD cycle, test file conventions, base classes, and per-layer run commands. See .claude/skills/dev-server/SKILL.md for server startup options.

Multi-user manual testing — setup_sig_session

Creates (or reuses) a room at table_status=SIG_SELECT with all 6 slots filled. Prints one pre-auth URL per gamer.

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>

Fixed gamers: founder@test.io (discoman), amigo@test.io, bud@test.io, pal@test.io, dude@test.io, bro@test.io — all superusers with Earthman deck. URLs use /dashboard/dev-login/<session_key>/ pre-auth pattern.

CI/CD + Hosting

  • 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 (staging.earthmanrpg.me)
  • Prod deploy: git tag v1.0.0 && git push --tags → triggers deploy-prod step (tag-based gate)
  • Two CI pipelines run in parallel: .woodpecker/main.yaml (main app) + .woodpecker/pyswiss.yaml (PySwiss at charts.earthmanrpg.me)
  • Multi-browser FTs tagged @tag("two-browser") run in a dedicated CI stage (test-two-browser-FTs) alongside --tag=channels; test-FTs stage is parallel-only
  • Hosting: DigitalOcean — main app on staging droplet; PySwiss on separate droplet (167.172.154.66)
  • Email: Mailgun (adman@howdy.earthmanrpg.me) | DNS: NameCheap

UI / Layout Conventions

Sidebar layout ($sidebar-w: 4rem)

Navbar is a fixed left sidebar; footer is a fixed right sidebar. Both are 4rem wide. Main container uses margin-left: $sidebar-w; margin-right: $sidebar-w. Landscape layout resets min-width to 0 on .gameboard-page and #id_dash_content (override of the @media (min-width: 738px) block that sets min-width: 666px).

Applet headings + page titles

  • Section headings: plain <h2> — browser default + body color inherited; no extra SCSS needed
  • Clickable headings: <h2><a href="...">Text</a></h2> — global body a rule supplies gold + hover glow
  • Page titles: <span>Dash</span>suffix pattern (Dashwallet, Dashnote, Dashnotes)

Position vs Seat terminology

Circles around the table hex are positions (gate slot order, 16). After role assignment they become seats (PC→NC→EC→SC→AC→BC). CSS carries both: .table-seat.table-position. SLOT_ROLE_LABELS = {1:"PC", 2:"NC", 3:"EC", 4:"SC", 5:"AC", 6:"BC"} in epic/views.py.

Game Architecture

Token priority chain

select_token(user) in apps.epic.models: PASS → COIN → FREE → TITHE → None. debit_token handles each type's consumption rules (Coin cooldown, Free/Tithe expiry).

Two-step gate token flow

Drop → RESERVED → confirm/reject. _gate_context() builds slot state; _expire_reserved_slots() clears stale reservations after 60s. Views: confirm_token, reject_token (renamed return_token).

Room URL routing

epic:room view at /gameboard/room/<uuid>/. gatekeeper redirects there when table_status is set. Error redirects in select_role/select_sig use epic:room if table_status is set, else epic:gatekeeper.

SCSS Import Order

core.scss: rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → natus → tray → billboard → tooltips → game-kit → wallet-tokens

Critical Gotchas

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

game-kit.js selection persistence

window._kitTokenId must NOT be cleared on kit-bag close — users close the dialog before clicking the rails button. Selection persists until page navigation. No clearSelection() in game-kit.js.

document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZoneno encodeURIComponent. Slashes in TZ names (America/New_York) are cookie-safe; encoding breaks the ZoneInfo lookup in TimezoneMiddleware.

CSS :has() for child-dependent styling

Use .parent:has(.child-class) to style a parent based on its contents without template changes. Example: .gate-slot:has(.drop-token-btn) makes CARTE OK-button circles match .reserved circles.

Plausible FT noise

Plausible analytics script in base.html fires a beacon during Selenium tests → harmless console error. Fix: {% if not debug %} guard around the script tag.

JSONField .exclude(data__key=value) on SQLite

.exclude(data__retracted=True) on a row whose data has no retracted key resolves to WHERE NOT (NULL = TRUE) → NULL → SQL filters that row out. The exclude becomes "exclude rows where the key is True OR missing" instead of "exclude rows where the key is True". PostgreSQL evaluates this correctly, so the bug only manifests in local dev / SQLite ITs. If you mean exclude only when the key exists and equals X, do the predicate in Python after fetching a buffered queryset (see _billboard_context for the pattern). The same trap applies to .filter(data__key=value) — you'll silently miss rows where the key is missing.

See .claude/skills/TDD/SKILL.md for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys).