# 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//`, 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 (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. ```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 ``` 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//` 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 `

` — browser default + body color inherited; no extra SCSS needed - Clickable headings: `

Text

` — global `body a` rule supplies gold + hover glow - Page titles: `Dashsuffix` pattern (Dashwallet, Dashnote, Dashnotes) ### Position vs Seat terminology Circles around the table hex are **positions** (gate slot order, 1–6). 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//`. `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`. ### Billboard timezone cookie `document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZone` — **no `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).