Compare commits
137 Commits
1558bb02b4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e9a9657b | ||
|
|
32d8d97360 | ||
|
|
df421fb6c0 | ||
|
|
3800c5bdad | ||
|
|
12d575a84b | ||
|
|
c14b6d7062 | ||
|
|
a7c5468cbc | ||
|
|
4da8750c60 | ||
|
|
cf40f626e6 | ||
|
|
99a826f6c9 | ||
|
|
51fe2614fa | ||
|
|
56dc094b45 | ||
|
|
520fdf7862 | ||
|
|
e2cc38686f | ||
|
|
0bcc7567bb | ||
|
|
6654785f25 | ||
|
|
99a69202b9 | ||
|
|
55bb450d27 | ||
|
|
e28d55ad58 | ||
|
|
b110bb6d01 | ||
|
|
2892b51101 | ||
|
|
871e94b298 | ||
|
|
c3ab78cc57 | ||
|
|
c7370bda03 | ||
|
|
a15d91dfe6 | ||
|
|
fecb1fddca | ||
|
|
2028f1a544 | ||
|
|
40c747a837 | ||
|
|
40a55721ab | ||
|
|
d4518a0671 | ||
|
|
74f63a7721 | ||
|
|
bd3d7fc7bd | ||
|
|
c00288e256 | ||
|
|
b5de96660a | ||
|
|
96bb05a4ba | ||
|
|
4e07fcf38b | ||
|
|
b74f8e1bb1 | ||
|
|
188365f412 | ||
|
|
824f35590b | ||
|
|
43cb84e8f4 | ||
|
|
afe8e2b32c | ||
|
|
ca38875660 | ||
|
|
8538f76b13 | ||
|
|
2a7d4c7410 | ||
|
|
ed10e58383 | ||
|
|
b65cba5ed2 | ||
|
|
afe79f1a48 | ||
|
|
0e5e39b0dc | ||
|
|
4860b6ee2a | ||
|
|
c025a38709 | ||
|
|
581ea7e349 | ||
|
|
596175cd1c | ||
|
|
1aaf353066 | ||
|
|
441def9a34 | ||
|
|
736b59b5c0 | ||
|
|
a8592aeaec | ||
|
|
8b006be138 | ||
|
|
299a806862 | ||
|
|
fb782cf5ef | ||
|
|
224f5e2ad0 | ||
|
|
96379934d7 | ||
|
|
29a5658b01 | ||
|
|
73135df7a6 | ||
|
|
57f47cc77e | ||
|
|
5d21e79be5 | ||
|
|
ff0883002b | ||
|
|
7f927741d4 | ||
|
|
3bf48546e3 | ||
|
|
6817323f8e | ||
|
|
11283118d6 | ||
|
|
6c91ec0385 | ||
|
|
39db59c71a | ||
|
|
5f643350c5 | ||
|
|
ab41797e57 | ||
|
|
e35855f472 | ||
|
|
0e5805efd2 | ||
|
|
de99b538d2 | ||
|
|
c08b5b764e | ||
|
|
d63a4bec4a | ||
|
|
b35c9b483e | ||
|
|
30ea0fad9d | ||
|
|
62d5c738f9 | ||
|
|
f0f419ff7e | ||
|
|
0494710ce0 | ||
|
|
713e24863d | ||
|
|
b3bc422f46 | ||
|
|
c0016418cc | ||
|
|
4d52c4f54d | ||
|
|
db1608fa38 | ||
|
|
4728cde771 | ||
|
|
2f6fc1ff20 | ||
|
|
9698d70164 | ||
|
|
7370fd611f | ||
|
|
f5a5ed9d8d | ||
|
|
a5d71925fc | ||
|
|
b03ba09b65 | ||
|
|
befa61e1e9 | ||
|
|
15ac3216ff | ||
|
|
2896efa8e0 | ||
|
|
588358a20f | ||
|
|
11c85d56d1 | ||
|
|
8bab26e003 | ||
|
|
bc78d2c470 | ||
|
|
2447315fd3 | ||
|
|
cde231d43c | ||
|
|
a0f8aeb791 | ||
|
|
2ca4e9d39f | ||
|
|
c71f4eb68c | ||
|
|
189d329e76 | ||
|
|
18898c7a0f | ||
|
|
f347af7eff | ||
|
|
e59d5fd4c0 | ||
|
|
62f6c27806 | ||
|
|
cc02419e8d | ||
|
|
c331e72de6 | ||
|
|
a1f8d294a3 | ||
|
|
5607f70852 | ||
|
|
eecb6c2be6 | ||
|
|
2fd3ec9ab2 | ||
|
|
cad3744a57 | ||
|
|
ffb374c81c | ||
|
|
3b905e0436 | ||
|
|
f1b5ba2a71 | ||
|
|
184854a2de | ||
|
|
f5c2cf4636 | ||
|
|
91e0eaad8e | ||
|
|
5a811d0079 | ||
|
|
8c2a5d24ec | ||
|
|
4f076165ef | ||
|
|
3a87a17017 | ||
|
|
4e63323019 | ||
|
|
8b2c4e1bdc | ||
|
|
10d717a3ba | ||
|
|
e9f50810da | ||
|
|
67697fa90e | ||
|
|
97b406c7e0 | ||
|
|
568497d09d |
@@ -23,6 +23,25 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
- name: test-FTs
|
- name: test-FTs
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
environment:
|
environment:
|
||||||
@@ -37,7 +56,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
161
CLAUDE.md
Normal 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.
|
||||||
@@ -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"]
|
||||||
@@ -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 }}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
daphne
|
||||||
dj-database-url
|
dj-database-url
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
celery
|
celery
|
||||||
|
cryptography
|
||||||
channels
|
channels
|
||||||
channels-redis
|
channels-redis
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
|||||||
0
src/apps/ap/__init__.py
Normal file
7
src/apps/ap/apps.py
Normal 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"
|
||||||
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal 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)
|
||||||
0
src/apps/ap/tests/unit/__init__.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal 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
@@ -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
@@ -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)
|
||||||
24
src/apps/applets/migrations/0005_gameboard_applet_heights.py
Normal 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,
|
||||||
|
)
|
||||||
|
]
|
||||||
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal 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),
|
||||||
|
]
|
||||||
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal 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),
|
||||||
|
]
|
||||||
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal 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)
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -28,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',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
0
src/apps/billboard/__init__.py
Normal file
6
src/apps/billboard/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BillboardConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.billboard'
|
||||||
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
189
src/apps/billboard/tests/integrated/test_views.py
Normal 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)
|
||||||
12
src/apps/billboard/urls.py
Normal 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"),
|
||||||
|
]
|
||||||
92
src/apps/billboard/views.py
Normal 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)
|
||||||
@@ -8,3 +8,36 @@ const initialize = (inputSelector) => {
|
|||||||
textInput.classList.remove("is-invalid");
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -58,6 +58,22 @@
|
|||||||
if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
|
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('.token[data-token-id]').forEach(function (card) {
|
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
@@ -69,22 +85,10 @@
|
|||||||
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);
|
||||||
card.addEventListener('mouseenter', function () {
|
|
||||||
var tooltip = card.querySelector('.token-tooltip');
|
|
||||||
if (!tooltip) return;
|
|
||||||
var rect = card.getBoundingClientRect();
|
|
||||||
tooltip.style.position = 'fixed';
|
|
||||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
|
||||||
tooltip.style.left = rect.left + 'px';
|
|
||||||
tooltip.style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
card.addEventListener('mouseleave', function () {
|
|
||||||
var tooltip = card.querySelector('.token-tooltip');
|
|
||||||
if (tooltip) tooltip.style.display = '';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dialog.querySelectorAll('.kit-bag-deck').forEach(attachTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,24 @@ 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
|||||||
UNLOCKED_PALETTES = frozenset([
|
UNLOCKED_PALETTES = frozenset([
|
||||||
"palette-default",
|
"palette-default",
|
||||||
"palette-sepia",
|
"palette-sepia",
|
||||||
"palette-monochrome-light",
|
"palette-oblivion-light",
|
||||||
"palette-monochrome-dark",
|
"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-sepia", "label": "Sepia", "locked": False},
|
||||||
{"name": "palette-monochrome-light", "label": "Monochrome (Light)", "locked": False},
|
{"name": "palette-oblivion-light", "label": "Oblivion (Light)", "locked": False},
|
||||||
{"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "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},
|
||||||
@@ -122,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="/")
|
||||||
@@ -179,6 +181,7 @@ def kit_bag(request):
|
|||||||
)
|
)
|
||||||
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
||||||
return render(request, "core/_partials/_kit_bag_panel.html", {
|
return render(request, "core/_partials/_kit_bag_panel.html", {
|
||||||
|
"equipped_deck": request.user.equipped_deck,
|
||||||
"equipped_trinket": request.user.equipped_trinket,
|
"equipped_trinket": request.user.equipped_trinket,
|
||||||
"free_token": free_tokens[0] if free_tokens else None,
|
"free_token": free_tokens[0] if free_tokens else None,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
|
|||||||
0
src/apps/drama/__init__.py
Normal file
19
src/apps/drama/admin.py
Normal 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
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DramaConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.drama'
|
||||||
32
src/apps/drama/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
30
src/apps/drama/migrations/0002_scrollposition.py
Normal 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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/apps/drama/migrations/__init__.py
Normal file
170
src/apps/drama/models.py
Normal 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)
|
||||||
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
116
src/apps/drama/tests/integrated/test_models.py
Normal 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)
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -1,18 +1,58 @@
|
|||||||
|
from channels.db import database_sync_to_async
|
||||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
|
|
||||||
|
|
||||||
|
LEVITY_ROLES = {"PC", "NC", "SC"}
|
||||||
|
GRAVITY_ROLES = {"BC", "EC", "AC"}
|
||||||
|
|
||||||
|
|
||||||
class RoomConsumer(AsyncJsonWebsocketConsumer):
|
class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
|
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
|
||||||
self.group_name = f"room_{self.room_id}"
|
self.group_name = f"room_{self.room_id}"
|
||||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
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()
|
await self.accept()
|
||||||
|
|
||||||
async def disconnect(self, close_code):
|
async def disconnect(self, close_code):
|
||||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
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):
|
async def receive_json(self, content):
|
||||||
pass # handlers added as events introduced
|
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):
|
async def gate_update(self, event):
|
||||||
await self.send_json(event)
|
await self.send_json(event)
|
||||||
@@ -23,5 +63,32 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
async def turn_changed(self, event):
|
async def turn_changed(self, event):
|
||||||
await self.send_json(event)
|
await self.send_json(event)
|
||||||
|
|
||||||
async def roles_revealed(self, 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)
|
await self.send_json(event)
|
||||||
|
|||||||
39
src/apps/epic/migrations/0007_tarotcard_tarotdeck.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
164
src/apps/epic/migrations/0008_seed_tarot_cards.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
202
src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py
Normal 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 0–51) ──────────────────────────
|
||||||
|
# (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),
|
||||||
|
]
|
||||||
82
src/apps/epic/migrations/0011_rename_earthman_court_cards.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -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 2–10
|
||||||
|
(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 6–9)
|
||||||
|
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 18–20)
|
||||||
|
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 21–24)
|
||||||
|
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 25–36)
|
||||||
|
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 37–38)
|
||||||
|
37: ("Absolute Element 1: Time", "absolute-element-1-time"),
|
||||||
|
38: ("Absolute Element 2: Space", "absolute-element-2-space"),
|
||||||
|
# Wanderers (cards 39–49)
|
||||||
|
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 2–10
|
||||||
|
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),
|
||||||
|
]
|
||||||
55
src/apps/epic/migrations/0013_earthman_coins_to_pentacles.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
63
src/apps/epic/migrations/0016_reorder_earthman_popes.py
Normal 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),
|
||||||
|
]
|
||||||
19
src/apps/epic/migrations/0017_tableseat_significator_fk.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/apps/epic/migrations/0018_alter_tarotcard_suit.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename The Schiz (card 0) and the five Pope cards (cards 1–5)
|
||||||
|
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),
|
||||||
|
]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename Pope cards 2–5 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),
|
||||||
|
]
|
||||||
@@ -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)),
|
||||||
|
]
|
||||||
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Data migration: Earthman deck — court cards and major arcana icons.
|
||||||
|
|
||||||
|
1. Court cards (numbers 11–14, all suits): arcana "MINOR" → "MIDDLE"
|
||||||
|
2. Major arcana icons (stored in TarotCard.icon):
|
||||||
|
0 (Nomad) → fa-hat-cowboy-side
|
||||||
|
1 (Schizo) → fa-hat-wizard
|
||||||
|
2–51 (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),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
# 21–38: 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
|
||||||
|
# 42–49: 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),
|
||||||
|
]
|
||||||
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal 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),
|
||||||
|
]
|
||||||
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-07 03:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0027_tarotcard_cautions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='suit',
|
||||||
|
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Data fix: clear Schizo cautions from The Nomad (number=0) if present,
|
||||||
|
and ensure they land on The Schizo (number=1).
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
SCHIZO_CAUTIONS = [
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Pervert</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">II. The Occultist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">II. Pestilence</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Paranoiac</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">III. The Despot</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">III. War</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Neurotic</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">IV. The Capitalist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">IV. Famine</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Suicidal</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">V. The Fascist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">V. Death</span>.',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def forward(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=0
|
||||||
|
).update(cautions=[])
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=1
|
||||||
|
).update(cautions=SCHIZO_CAUTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=1
|
||||||
|
).update(cautions=[])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0028_alter_tarotcard_suit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, reverse_code=reverse),
|
||||||
|
]
|
||||||
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0029_fix_schizo_cautions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='seat',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='sig_reservation',
|
||||||
|
to='epic.tableseat',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-09 04:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0030_sigreservation_seat_fk'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='sig_select_started_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='countdown_remaining',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='ready',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='table_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('SKY_SELECT', 'Sky Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import UniqueConstraint
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -31,10 +33,12 @@ class Room(models.Model):
|
|||||||
|
|
||||||
ROLE_SELECT = "ROLE_SELECT"
|
ROLE_SELECT = "ROLE_SELECT"
|
||||||
SIG_SELECT = "SIG_SELECT"
|
SIG_SELECT = "SIG_SELECT"
|
||||||
|
SKY_SELECT = "SKY_SELECT"
|
||||||
IN_GAME = "IN_GAME"
|
IN_GAME = "IN_GAME"
|
||||||
TABLE_STATUS_CHOICES = [
|
TABLE_STATUS_CHOICES = [
|
||||||
(ROLE_SELECT, "Role Select"),
|
(ROLE_SELECT, "Role Select"),
|
||||||
(SIG_SELECT, "Significator Select"),
|
(SIG_SELECT, "Significator Select"),
|
||||||
|
(SKY_SELECT, "Sky Select"),
|
||||||
(IN_GAME, "In Game"),
|
(IN_GAME, "In Game"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -48,6 +52,7 @@ class Room(models.Model):
|
|||||||
table_status = models.CharField(
|
table_status = models.CharField(
|
||||||
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
sig_select_started_at = models.DateTimeField(null=True, blank=True)
|
||||||
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
board_state = models.JSONField(default=dict)
|
board_state = models.JSONField(default=dict)
|
||||||
@@ -147,6 +152,9 @@ def debit_token(user, slot, token):
|
|||||||
room.save()
|
room.save()
|
||||||
|
|
||||||
|
|
||||||
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
class TableSeat(models.Model):
|
class TableSeat(models.Model):
|
||||||
PC = "PC"
|
PC = "PC"
|
||||||
BC = "BC"
|
BC = "BC"
|
||||||
@@ -173,3 +181,292 @@ class TableSeat(models.Model):
|
|||||||
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
||||||
role_revealed = models.BooleanField(default=False)
|
role_revealed = models.BooleanField(default=False)
|
||||||
seat_position = models.IntegerField(null=True, blank=True)
|
seat_position = models.IntegerField(null=True, blank=True)
|
||||||
|
significator = models.ForeignKey(
|
||||||
|
"TarotCard", null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="significator_seats",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeckVariant(models.Model):
|
||||||
|
"""A named deck variant, e.g. Earthman (108 cards) or Fiorentine Minchiate (78 cards)."""
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_key(self):
|
||||||
|
"""First dash-separated word of slug — used as an HTML id component."""
|
||||||
|
return self.slug.split('-')[0]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.card_count} cards)"
|
||||||
|
|
||||||
|
|
||||||
|
class TarotCard(models.Model):
|
||||||
|
MAJOR = "MAJOR"
|
||||||
|
MINOR = "MINOR"
|
||||||
|
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
|
||||||
|
ARCANA_CHOICES = [
|
||||||
|
(MAJOR, "Major Arcana"),
|
||||||
|
(MINOR, "Minor Arcana"),
|
||||||
|
(MIDDLE, "Middle Arcana"),
|
||||||
|
]
|
||||||
|
|
||||||
|
WANDS = "WANDS"
|
||||||
|
CUPS = "CUPS"
|
||||||
|
SWORDS = "SWORDS"
|
||||||
|
PENTACLES = "PENTACLES" # Fiorentine 4th suit
|
||||||
|
CROWNS = "CROWNS" # Earthman 4th suit
|
||||||
|
BRANDS = "BRANDS" # Earthman Wands
|
||||||
|
GRAILS = "GRAILS" # Earthman Cups
|
||||||
|
BLADES = "BLADES" # Earthman Swords
|
||||||
|
SUIT_CHOICES = [
|
||||||
|
(WANDS, "Wands"),
|
||||||
|
(CUPS, "Cups"),
|
||||||
|
(SWORDS, "Swords"),
|
||||||
|
(PENTACLES, "Pentacles"),
|
||||||
|
(CROWNS, "Crowns"),
|
||||||
|
(BRANDS, "Brands"),
|
||||||
|
(GRAILS, "Grails"),
|
||||||
|
(BLADES, "Blades"),
|
||||||
|
]
|
||||||
|
|
||||||
|
deck_variant = models.ForeignKey(
|
||||||
|
DeckVariant, null=True, blank=True,
|
||||||
|
on_delete=models.CASCADE, related_name="cards",
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES)
|
||||||
|
suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
|
||||||
|
icon = models.CharField(max_length=50, blank=True, default='') # FA icon override (e.g. major arcana)
|
||||||
|
number = models.IntegerField() # 0–21 major (Fiorentine); 0–51 major (Earthman); 1–14 minor
|
||||||
|
slug = models.SlugField(max_length=120)
|
||||||
|
correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent
|
||||||
|
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
||||||
|
keywords_upright = models.JSONField(default=list)
|
||||||
|
keywords_reversed = models.JSONField(default=list)
|
||||||
|
cautions = models.JSONField(default=list)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["deck_variant", "arcana", "suit", "number"]
|
||||||
|
unique_together = [("deck_variant", "slug")]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_roman(n):
|
||||||
|
if n == 0:
|
||||||
|
return '0'
|
||||||
|
val = [50, 40, 10, 9, 5, 4, 1]
|
||||||
|
syms = ['L','XL','X','IX','V','IV','I']
|
||||||
|
result = ''
|
||||||
|
for v, s in zip(val, syms):
|
||||||
|
while n >= v:
|
||||||
|
result += s
|
||||||
|
n -= v
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def corner_rank(self):
|
||||||
|
if self.arcana == self.MAJOR:
|
||||||
|
return self._to_roman(self.number)
|
||||||
|
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
|
||||||
|
return court.get(self.number, str(self.number))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_group(self):
|
||||||
|
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
|
||||||
|
if ': ' in self.name:
|
||||||
|
return self.name.split(': ', 1)[0] + ':'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_title(self):
|
||||||
|
"""Returns the title after 'Group N: ', or the full name if no colon."""
|
||||||
|
if ': ' in self.name:
|
||||||
|
return self.name.split(': ', 1)[1]
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def suit_icon(self):
|
||||||
|
if self.icon:
|
||||||
|
return self.icon
|
||||||
|
if self.arcana == self.MAJOR:
|
||||||
|
return ''
|
||||||
|
return {
|
||||||
|
self.WANDS: 'fa-wand-sparkles',
|
||||||
|
self.CUPS: 'fa-trophy',
|
||||||
|
self.SWORDS: 'fa-gun',
|
||||||
|
self.PENTACLES: 'fa-star',
|
||||||
|
self.CROWNS: 'fa-crown',
|
||||||
|
self.BRANDS: 'fa-wand-sparkles',
|
||||||
|
self.GRAILS: 'fa-trophy',
|
||||||
|
self.BLADES: 'fa-gun',
|
||||||
|
}.get(self.suit, '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cautions_json(self):
|
||||||
|
import json
|
||||||
|
return json.dumps(self.cautions)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class TarotDeck(models.Model):
|
||||||
|
"""One shuffled deck per room, scoped to the founder's chosen DeckVariant."""
|
||||||
|
|
||||||
|
room = models.OneToOneField(Room, on_delete=models.CASCADE, related_name="tarot_deck")
|
||||||
|
deck_variant = models.ForeignKey(
|
||||||
|
DeckVariant, null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="active_decks",
|
||||||
|
)
|
||||||
|
drawn_card_ids = models.JSONField(default=list)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_count(self):
|
||||||
|
total = self.deck_variant.card_count if self.deck_variant else 0
|
||||||
|
return total - len(self.drawn_card_ids)
|
||||||
|
|
||||||
|
def draw(self, n=1):
|
||||||
|
"""Draw n cards at random. Returns list of (TarotCard, reversed: bool) tuples."""
|
||||||
|
available = list(
|
||||||
|
TarotCard.objects.filter(deck_variant=self.deck_variant)
|
||||||
|
.exclude(id__in=self.drawn_card_ids)
|
||||||
|
)
|
||||||
|
if len(available) < n:
|
||||||
|
raise ValueError(
|
||||||
|
f"Not enough cards remaining: {len(available)} available, {n} requested"
|
||||||
|
)
|
||||||
|
drawn = random.sample(available, n)
|
||||||
|
self.drawn_card_ids = self.drawn_card_ids + [card.id for card in drawn]
|
||||||
|
self.save(update_fields=["drawn_card_ids"])
|
||||||
|
return [(card, random.choice([True, False])) for card in drawn]
|
||||||
|
|
||||||
|
def shuffle(self):
|
||||||
|
"""Reset the deck so all variant cards are available again."""
|
||||||
|
self.drawn_card_ids = []
|
||||||
|
self.save(update_fields=["drawn_card_ids"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────
|
||||||
|
|
||||||
|
class SigReservation(models.Model):
|
||||||
|
LEVITY = 'levity'
|
||||||
|
GRAVITY = 'gravity'
|
||||||
|
POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]
|
||||||
|
|
||||||
|
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
|
||||||
|
gamer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
|
||||||
|
)
|
||||||
|
seat = models.ForeignKey(
|
||||||
|
'TableSeat', null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name='sig_reservation',
|
||||||
|
)
|
||||||
|
card = models.ForeignKey(
|
||||||
|
'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
|
||||||
|
)
|
||||||
|
role = models.CharField(max_length=2)
|
||||||
|
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
|
||||||
|
reserved_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
ready = models.BooleanField(default=False)
|
||||||
|
countdown_remaining = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
UniqueConstraint(
|
||||||
|
fields=['room', 'gamer'],
|
||||||
|
name='one_sig_reservation_per_gamer_per_room',
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
fields=['room', 'card', 'polarity'],
|
||||||
|
name='one_reservation_per_card_per_polarity_per_room',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sig_deck_cards(room):
|
||||||
|
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||||
|
|
||||||
|
PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
|
||||||
|
SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (11–14): 8 unique
|
||||||
|
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
||||||
|
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
||||||
|
"""
|
||||||
|
deck_variant = room.owner.equipped_deck
|
||||||
|
if deck_variant is None:
|
||||||
|
return []
|
||||||
|
wands_crowns = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
swords_cups = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
major = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MAJOR,
|
||||||
|
number__in=[0, 1],
|
||||||
|
))
|
||||||
|
unique_cards = wands_crowns + swords_cups + major # 18 unique
|
||||||
|
return unique_cards + unique_cards # × 2 = 36
|
||||||
|
|
||||||
|
|
||||||
|
def _sig_unique_cards(room):
|
||||||
|
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
||||||
|
deck_variant = room.owner.equipped_deck
|
||||||
|
if deck_variant is None:
|
||||||
|
return []
|
||||||
|
wands_crowns = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
swords_cups = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
major = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MAJOR,
|
||||||
|
number__in=[0, 1],
|
||||||
|
))
|
||||||
|
return wands_crowns + swords_cups + major
|
||||||
|
|
||||||
|
|
||||||
|
def levity_sig_cards(room):
|
||||||
|
"""The 18 cards available to the levity group (PC/NC/SC)."""
|
||||||
|
return _sig_unique_cards(room)
|
||||||
|
|
||||||
|
|
||||||
|
def gravity_sig_cards(room):
|
||||||
|
"""The 18 cards available to the gravity group (BC/EC/AC)."""
|
||||||
|
return _sig_unique_cards(room)
|
||||||
|
|
||||||
|
|
||||||
|
def sig_seat_order(room):
|
||||||
|
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
|
||||||
|
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
|
||||||
|
seats = list(room.table_seats.all())
|
||||||
|
return sorted(seats, key=lambda s: _order.get(s.role, 99))
|
||||||
|
|
||||||
|
|
||||||
|
def active_sig_seat(room):
|
||||||
|
"""Return the first seat without a significator in canonical order, or None."""
|
||||||
|
for seat in sig_seat_order(room):
|
||||||
|
if seat.significator_id is None:
|
||||||
|
return seat
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #354a9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #381507;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
stroke-width: 2.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3, .cls-4 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #4f66d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #4258b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-7 {
|
||||||
|
fill: #3d180d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-8 {
|
||||||
|
fill: #3a1709;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-5" d="M185.31,203.37l.12.54,1.16,5.4-2.22.49c-.08,8.17-.62,15.76-1.54,24.08-.26,2.33.18,7.31,3.04,7.58,4.78.44,9.33-1.88,14.1-2.1,1.59-.07,3.44-.05,4.69-.32l.98,9.54c.24,2.25,2.12,2.5,3.74,3.16,1.99.81,2.31,3.55,3.46,5.16l3.75,5.23c.62.86,1.45,1.66,2.51,1.36-.12,2.28.15,4.61.11,7.14l-.44,1.49c-1.47-.84-2.37,1.63-3.55,1.77l-16.08,1.97-21.66,2.39c4.99,1.47,9.61,1.45,14.55,2.03l23.5,2.78c.22,1.66.49.84,1.47.31,1.01,6.14,2.22,12.45,2.53,19.3,1.2,4.17.5,8.59-3.88,10.35l-3.91,2.35c.65.86,1.46,1.29,1.81,2.27,2.77,1.35,3.4,4.57,3.09,7.75,2.44,5.7,2.42,11.51,1.87,17.57l-.91,9.96c-.14,1.55-1.69,1.51-2.77,1.41l-17.57-1.6c-4.26-.39-8.71.46-12.16-2.84l-5.53-7.04c-.25.2-.35.47-.36.58,0-.11.11-.38.36-.58-.25.2-.35.47-.36.58-.28,3.07-.81,5.92-2.85,8.97l-21.21,1.95c-.12-1.76-.26-3.48-1.01-5.15l-4.42-9.91-3.75-25.3c-.14-.97-.63-1.8-1.07-2.16.43-1.73-.03-4.16-.58-6.18-1.9-4.98-3.59-9.83-4.83-15.35.18-.51-1.33-1.69-.39-2.35l1.62-1.16c1-.71-.63-1.33-.17-2.07.4-.66,1.75-.46,1.97-1.2.33-1.08-.35-2.18-1.64-2.16l-4.2.07-9.67-35.31c-.2-.75-.95-.91-1.34-1.05-.54-.19-1.5,0-1.53.93-.02.66-.64,1.72-1.09,2.78-1.2-1.34-4.74-2.35-5.46-.22l-12.57,37.25-7.39,1.96-1.8.57c-.58.18-.63,2.12,0,2.14,1.81.06,3.73-1.13,5.06.44-1.62.37-4.59.86-3.54,2.77.86,1.55,3.62.48,4.95.31l-4.78,15.62c-.24.78.03,1.76.57,2.34.4.43,1.33.72,2.04-.13,1.08-1.29,1.25-3.2,1.82-4.45.97-.38,2.22.37.73.97-.13,1.21-.23,2.19.18,3.14.23.53.84.92,2.01.61,2.71-6.4,4.7-12.72,6.8-19.73l18.11-2.9,5.96,21.86c.44,1.61,1.47,1.6,2.91,1.32,1.12-.22.51-1.69.51-2.99v-1.31c0-.37.34-.39,1.2-.31-1.04,1.12-.03,2.08.5,3.48.36.96,1.5,1.25,2.74,1.23l-.5,20.05c-.09,3.58-.56,6.95-.22,10.34l-21.98.14-2.92-9.49c2.68-4.69,5.77-9.41,3.04-13.61l-6.91,1.61c-1.59.37.86,2.08.68,2.8l-1.98,7.57c-.27,1.01.11,2.26-.46,3.03-1.37,1.88-6.3,1.61-9.49.15-1.3-2.98,1.24-7.91.16-13.45-.44-2.25-3.52-1.29-5.21-1.15l-8.08.67-6.35-5.57c.37-4.82-6.34-6.88-5.95-12.46.07-.96,1.36-1.31,1.96-2.42l5.08-9.34c.18-1.24-1.67-1.79-2.25-2.13l-3.45-1.97c-1-.57-1.54-1.46-1.61-2.3-.11-1.23,3.32-3.74,6.12-4.08l23.77-2.92c-4.84-1.4-9.6-.46-14.47-.77l-20.58-1.25-1.75-17.45c-.27-2.73-1.52-5.99.31-8.64,1.48-2.15,4.9-2.45,7.04-3.85,3.01-1.97-4.02-5.01-3.67-6.6,1.58-7.04-3.55-9.14-2.92-17.11l1.35-16.91c19.69,3.11,39.13,3.08,58.78.65,2.37-.29,3.87.88,5.27,2.25l.56.54-.56-.54-2.23,1.33c3.23,4.36,4.37,8.99,5.14,13.85l2.71,17.1,3.13,17.22c.82-.18.95-1.1.85-1.6-.94-4.55-.24-9.14-.6-13.84-.32-4.1-.56-7.97.06-12.11.92-6.26,1.04-12.4-.43-18.5-.18-.76-1.4-.95-1.7-.74.48-1.08.49-3.87,2.16-4.43l15.4-1.26,24.35-2.21ZM187.63,257.75c.4.93.52,1.9,1.14,2.91,2.36,3.88,4.35-8.06-7.31-13.58-7.5-3.55-15.29-1.54-21.1,4.83-13.04,14.31-17.28,39.69-4.52,53.8,5.7,6.31,14.78,8.03,22.8,5.87,13.32-3.6,20.27-20.17,13.32-16.11-.83,1.08-1.92.91-1.28-.45.23-1.19-.5-2.25-1.19-2.44-2.65-.75-3.54,8.1-13.46,10.47-5.92,1.42-11.86-1.51-14.55-6.83-4.2-8.32-4.34-17.68-1.05-26.64,3.45-9.39,10.35-17.68,18.54-13.94,4.41,2.01,4.83,8.81,5.68,8.88s1.2-.15,1.64-.32c.27-.1,1.75-2.67.35-6.47.34.07.6.1.97.03Z"/>
|
||||||
|
<path class="cls-6" d="M142.89,343.17l.29,2.88c.86.07,1.58.05,2.04.23.67.25,1.91,1.3,1.32,2.05-1.46,1.19-1.67,3.08-2.56,5.44l-38.54,2.82c-2.19.16-4,.22-5.07-1.87-.06.09-.12.09-.17.04l-31.82-30.7.34-.51,6.2,1.52c6.89,2.24,14.28,1.74,21.71,1.33,1.56-.09,1.37,2.99,1.43,3.85-.3,7.59-.1,14.62,2.15,21.47.05.41.41.65.83.61.39-.4.95-.17.83-.59l-.37-1.33.4-13.65c.01-.34.23-.76.19-.97-.05-.23.41-.49.79-.42,3.19,1.46,8.11,1.72,9.49-.15.56-.77.19-2.01.46-3.03l1.98-7.57c.19-.72-2.26-2.43-.68-2.8l6.91-1.61c2.74,4.2-.36,8.92-3.04,13.61l2.92,9.49,21.98-.14Z"/>
|
||||||
|
<path class="cls-6" d="M89.74,321.43c-1.43.12-3.07.88-4.9.94l-9.56.29c-.16,1.21.7,1.9-.37,2.41l-6.2-1.52-.57-.13-.57-.14c.06-.16.14-.32.15-.48l1.45-15.57,1.33-17.08,1.52-13.95,20.58,1.25c4.86.31,9.63-.63,14.47.77l-23.77,2.92c-2.8.34-6.23,2.85-6.12,4.08.07.84.61,1.73,1.61,2.3l3.45,1.97c.58.33,2.43.89,2.25,2.13l-5.08,9.34c-.6,1.11-1.89,1.46-1.96,2.42-.4,5.59,6.32,7.64,5.95,12.46l6.35,5.57Z"/>
|
||||||
|
<path class="cls-5" d="M185.31,203.37l.41.36,32.83,32.44c.15.15.23.32.17.52l-3.36-.08c-.18,0-.37-.17-.52.11-9.2-3.04-24.62.27-26.12-.81-2.61-1.89,1.62-13.42-2.11-26.59l-1.16-5.4-.12-.54Z"/>
|
||||||
|
<path class="cls-6" d="M219.09,263.48c-1.07.3-1.89-.5-2.51-1.36l-3.75-5.23c-1.15-1.61-1.47-4.34-3.46-5.16-1.62-.66-3.5-.91-3.74-3.16l-.98-9.54c2.56-.56,5.24-.75,8.24-.37l1.71-.29c.49-.08,1.13-1.43.74-1.77-.18,0-.37-.17-.52.11.15-.28.34-.11.52-.11l3.36.08c.06-.2-.01-.36-.17-.52.15.15.23.32.17.52,2.85,1.17,1.38,7.41,1.07,13.48l-.68,13.31Z"/>
|
||||||
|
<path class="cls-6" d="M137.02,209.09l5.09,4.92c1.03-.68.94-1.93,1.29-2.74.3-.21,1.51-.02,1.7.74,1.47,6.1,1.35,12.23.43,18.5-.61,4.14-.37,8.01-.06,12.11.37,4.7-.33,9.3.6,13.84.1.5-.03,1.42-.85,1.6l-3.13-17.22-2.71-17.1c-.77-4.87-1.91-9.49-5.14-13.85l2.23-1.33.56.54Z"/>
|
||||||
|
<path class="cls-1" d="M155.13,354.34l-6.55-5.25c-.5-.4-.58-1.25-1.2-1.09-.28.07-.6.17-.85.32.59-.75-.65-1.8-1.32-2.05-.47-.17-1.18-.15-2.04-.23l-.29-2.88c-.34-3.39.13-6.76.22-10.34l.5-20.05c.35-.26.61-.87,1.26-.95.44.36.93,1.18,1.07,2.16l3.75,25.3,4.42,9.91c.75,1.67.89,3.39,1.01,5.15Z"/>
|
||||||
|
<path class="cls-1" d="M218.76,272.12c.55,2.96-1.28,5.06-3.13,7.88-.92,1.4,1.16,2.13,1.36,3.36-.98.53-1.25,1.36-1.47-.31l-23.5-2.78c-4.93-.58-9.56-.56-14.55-2.03l21.66-2.39,16.08-1.97c1.17-.14,2.07-2.61,3.55-1.77Z"/>
|
||||||
|
<path class="cls-8" d="M144.88,311.82c-.66.08-.92.69-1.26.95-1.24.01-2.37-.28-2.74-1.23-.53-1.4-1.54-2.36-.5-3.48-.86-.08-1.19-.06-1.2.31v1.31c0,1.3.6,2.76-.52,2.99-1.43.29-2.47.29-2.91-1.32l-5.96-21.86-18.11,2.9c-2.1,7.01-4.09,13.33-6.8,19.73-1.17.31-1.78-.08-2.01-.61-.41-.95-.31-1.94-.18-3.14,1.5-.6.24-1.35-.73-.97-.56,1.24-.74,3.16-1.82,4.45-.71.85-1.64.57-2.04.13-.54-.58-.8-1.56-.57-2.34l4.78-15.62c-1.33.17-4.1,1.25-4.95-.31-1.05-1.92,1.92-2.4,3.54-2.77-1.34-1.57-3.26-.38-5.06-.44-.62-.02-.58-1.96,0-2.14l1.8-.57,7.39-1.96,12.57-37.25c.72-2.13,4.25-1.12,5.46.22.45-1.06,1.07-2.12,1.09-2.78.03-.94.98-1.12,1.53-.93.39.13,1.13.3,1.34,1.05l9.67,35.31,4.2-.07c1.29-.02,1.98,1.07,1.64,2.16-.23.73-1.57.54-1.97,1.2-.45.74,1.17,1.36.17,2.07l-1.62,1.16c-.93.67.57,1.85.39,2.35,1.24,5.52,2.93,10.37,4.83,15.35.55,2.02,1.01,4.45.58,6.18ZM122.28,263.01l-7.68,21.2,13.37-1.5-5.69-19.69Z"/>
|
||||||
|
<path class="cls-7" d="M187.63,257.75l-1.21-2.81c-.33-.78-.83-2.11-2.39-2.23l-.53-1.37c-.16-.41-.82-.21-1.64-.58-1.01-.56-1.29.55.06.58,2.16,2.07,3.86,4.01,4.73,6.38,1.4,3.8-.08,6.37-.35,6.47-.45.17-.81.39-1.64.32s-1.27-6.87-5.68-8.88c-8.19-3.73-15.09,4.55-18.54,13.94-3.29,8.97-3.16,18.32,1.05,26.64,2.69,5.33,8.63,8.25,14.55,6.83,9.92-2.37,10.81-11.22,13.46-10.47.69.19,1.42,1.25,1.19,2.44-.64,1.37.45,1.54,1.28.45,6.95-4.06,0,12.51-13.32,16.11-8.02,2.17-17.1.44-22.8-5.87-12.75-14.1-8.52-39.49,4.52-53.8,5.8-6.37,13.59-8.37,21.1-4.83,11.66,5.51,9.67,17.45,7.31,13.58-.61-1.01-.74-1.98-1.14-2.91ZM157.46,302.61l3.6,3.77c.64.67.95.15,1.82.3.52.09-.23-.61-.29-.93-.07-.41-1.13-.1-1.37-.35-1.17-1.26-1.91-3.37-3.77-2.78Z"/>
|
||||||
|
<path class="cls-1" d="M186.59,209.31c3.74,13.17-.49,24.7,2.11,26.59,1.5,1.08,16.92-2.22,26.12.81.15-.28.34-.11.52-.11.4.33-.25,1.68-.74,1.77l-1.71.29c-3-.38-5.68-.19-8.24.37-1.25.27-3.1.25-4.69.32-4.76.22-9.32,2.54-14.1,2.1-2.86-.26-3.3-5.24-3.04-7.58.92-8.32,1.46-15.9,1.54-24.08l2.22-.49Z"/>
|
||||||
|
<path class="cls-1" d="M102.87,335.37c-.38-.07-.84.19-.79.42.05.21-.18.63-.19.97l-.4,13.65.37,1.33c.12.42-.44.19-.83.59-.42.03-.78-.2-.83-.61-2.25-6.85-2.45-13.87-2.15-21.47-.06-.86.13-3.93-1.43-3.85-7.43.41-14.82.91-21.71-1.33,1.07-.51.21-1.2.37-2.41l9.56-.29c1.83-.06,3.48-.82,4.9-.94l8.08-.67c1.68-.14,4.77-1.1,5.21,1.15,1.08,5.55-1.46,10.47-.16,13.45Z"/>
|
||||||
|
<path class="cls-1" d="M187.63,257.75c-.37.07-.64.04-.97-.03-.88-2.38-2.57-4.31-4.73-6.38-1.35-.04-1.07-1.15-.06-.58.82.37,1.48.17,1.64.58l.53,1.37c1.56.12,2.06,1.45,2.39,2.23l1.21,2.81Z"/>
|
||||||
|
<polygon class="cls-5" points="122.28 263.01 127.97 282.71 114.6 284.21 122.28 263.01"/>
|
||||||
|
<path class="cls-1" d="M157.46,302.61c1.86-.59,2.59,1.52,3.77,2.78.23.25,1.3-.06,1.37.35.05.32.81,1.02.29.93-.87-.15-1.18.38-1.82-.3l-3.6-3.77Z"/>
|
||||||
|
<path class="cls-2" d="M210.46,277.3l6.02,1.81c1.38.43.78,2.5-.62,2.11,0,0-6.03-1.81-6.03-1.81-1.97-.86-4.17-1.86-6.07-2.91,1.33.16,5.03.6,6.7.81h0Z"/>
|
||||||
|
<path class="cls-4" d="M185.31,203.37l-24.35,2.21-15.4,1.26c-1.66.57-1.68,3.35-2.16,4.43-.36.81-.27,2.06-1.29,2.74l-5.09-4.92-.56-.54c-1.41-1.37-2.9-2.54-5.27-2.25-19.66,2.42-39.1,2.45-58.78-.65l-1.35,16.91c-.64,7.97,4.5,10.07,2.92,17.11-.36,1.59,6.68,4.62,3.67,6.6-2.14,1.4-5.56,1.7-7.04,3.85-1.83,2.66-.58,5.92-.31,8.64l1.75,17.45-1.52,13.95-1.33,17.08-1.45,15.57-.15.48"/>
|
||||||
|
<path class="cls-4" d="M185.32,203.34l.4.39,32.83,32.44c.15.15.23.32.17.52l-3.36-.08c-.18,0-.37-.17-.52.11-9.2-3.04-24.62.27-26.12-.81-2.61-1.89,1.62-13.42-2.11-26.59l-1.16-5.4-.11-.58Z"/>
|
||||||
|
<path class="cls-4" d="M213.54,317.63c2.77,1.35,3.4,4.57,3.09,7.75,2.44,5.7,2.42,11.51,1.87,17.57l-.91,9.96c-.14,1.55-1.69,1.51-2.77,1.41l-17.57-1.6c-4.26-.39-8.71.46-12.16-2.84l-5.53-7.04c-.25.2-.35.47-.36.58-.28,3.07-.81,5.92-2.85,8.97l-21.21,1.95-6.55-5.25c-.5-.4-.58-1.25-1.2-1.09-.28.07-.6.17-.85.32-1.46,1.19-1.67,3.08-2.56,5.44l-38.54,2.82c-2.19.16-4,.22-5.07-1.87-.62-.8-.65-1.96-.17-3.01-2.25-6.85-2.45-13.87-2.15-21.47-.06-.86.13-3.93-1.43-3.85-7.43.41-14.82.91-21.71-1.33l-6.2-1.52-.57-.13-.57-.14"/>
|
||||||
|
<polyline class="cls-3" points="100.19 354.76 68.38 324.06 67.57 323.28"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #39170a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #6b1f65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #852f7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #9e3d96;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-6" d="M134.56,198.73c-.18.4-.63,1.06-.86,1.01l-1.53-.32,2.73,6.11c3.16,4.21,3.45,9.15,4.36,14.26l5.43,30.42-.31-26.42.69-6.77-.39-15.14c.78.13,1.2.48,1.86.21.09,1.59-.38,3.25-.01,4.91,3.95,1.89,3.31,4.73,5.82,4.74,1.17,0,3.35.51,3.8-.95.88-2.85,3.04-3.98,3.96-8.34,2.01-.62,4.59.12,5.2,2.41l2.94,4.65.47,2.59c.11.63,3.07.5,5.91,4.7.74-2.1,2.63-2.9,4.18-3.47,2.1-.76,3.81.92,5.23,2.46l1.28.36-1.2,10.28c-.16,1.36.06,4.75,1.34,5.26l12.32-1.33c1.3-.14,2.73-.73,4.27-.46-.74,2.06,2.02,3.53,2.3,4.95.63,3.15.89,5.85,3.93,7.95,1.18.81,2.14,2.65,4.4,1.82l2.14,4.34c-1.97,3.85-2.69,7.52-1.18,11.55l-7.95,3.92-9.59,1.55-24.02,4.56,31.72.18c3.86-1.21,7.59-.06,10.11,3.3-3.28,1.87-2.97,4.67-2,7.19,3.17,8.23-.69,15.5-7.22,20.39-2.27,2.33-3.26,5.96-4.66,8.69l-14.97-1.08-1.98.67c-1.17.4-1.4,6.07-1.2,10.28l-5.97.64c.01,1.31.89,2.27,1.35,3.52l-3.55,5.37c-5.53-.62-8.28,7-13.89,10.9-1.98,1.37-3.73-.25-5.2-.67-1.79-.51-3.25-.83-4.12-2.65-1.17.63-2.28.31-3.09,0-.93-.36-1.45-1.42-1.58-2.63l-1.24-12.07-.54-21.41-3.37,26.93-2.32,11.58c-1.35-.6-2.56-.79-3.93-.64-2.87.32-5.45-.38-8.31-.41-1.55-.02-1.88-1.53-3.11-3.07l2.02-2.29c-1.1-2.21-3.48-2.22-5.28-3.2-.75-.41-1.27-1.25-2.14-1.2-2.2.13.67,6.44-2.89,6.37l-6.18-.11c-.97-.02-1.52-.97-1.46-1.38l.36-2.39c-2.16-3.31-6.64-4.97-10.34-6.05l-4.61-1.35,9.71-11.1c1.03-1.17,2.56-2.27,2.2-3.91l-10.97,8.35c-4.57-1.25-8.28-3.55-8.21-8.02-.63-.81-1.9-1.86-1.64-2.9.76-2.95,3.93-2.39,4-6.01.03-1.5-1.05-2.01-2.21-3.07-1.5-1.37-2.59-3.79-4.86-4.28-1.89-.41-6.44-4.24-7.02-6.09-1.01-3.25,2.85-5.63.92-8.2l8.46-2.25,18.21-3.46.55,22.34-4.07-1.09c-.62-.17-1.15.71-1.26,1.11-.71,2.66,6.57,4.49,6.46,4.96-.06.26-.4.99-.55.77-.26-.37-.68-.81-1.29-.89l-2.57-.33c-.63-.08-1.82,2.04-.78,2.4,13.51,4.72,30.52,4.52,40.19-6.04,4.26-4.65,7-10.96,5.44-17.11-1.88-7.43-8.07-12.69-15.99-13.65,4.61-3.97,8.07-9.52,6.06-15.48s-7.82-9.61-13.78-10.33c-13.8-1.66-26.43,7.86-23.9,11.48l1.82.27c.83.77.9,1.54,1.58,1.28l2.21-.86-.14,17.59-10.69.46-11.02-.53c-3.1-.15-5.69-2.07-8.52-1.76l-2.44-19.92c-1.31-2.93-.76-6.46,2.46-7.96,3.42-1.59,4.59-3.37,5.99-2.92.03-2.33-2.37-3.38-4.36-4.69l-.32-6.53c-5.46-9.7-.92-17.59-1.51-28.43l16.23,2.03,23.56-.02,16.48-1.23c2.97-.22,5.24-1.39,7.61.8ZM185.54,293.84c2.42-3.82,5.39-7.88,3.02-9.23s-3.38,6.71-11.7,9.93c-4.18,1.62-8.99.92-12.68-1.81-3.38-2.5-5.13-6.55-6.15-11.09-3.58-15.97,6.89-36.59,18.87-32.85,6.82,2.13,4.36,12.37,8.6,8.12.92-1.52.02-3.6.33-5.64l2.51,3.38c1.54-.91,1.02-2.87.63-4.33-1.61-6.12-7.34-9.87-13.12-11.04-6.3-1.28-11.96,1.46-16.42,6.15-6.23,6.56-9.49,15.21-11.29,23.96-2.95,14.33,3.3,32.61,19.18,34.63,11.19,1.43,22.11-4.68,26.13-15.27.19-.49.04-1.09.04-1.41,0-.73-2.81-.21-2.68-.3-1.54,1.13-1.96,6.72-5.28,6.8Z"/>
|
||||||
|
<path class="cls-3" d="M74.46,278.72c1.93,2.57-1.93,4.94-.92,8.2.57,1.85,5.13,5.69,7.02,6.09,2.27.49,3.37,2.91,4.86,4.28,1.15,1.06,2.24,1.57,2.21,3.07-.07,3.62-3.24,3.06-4,6.01-.26,1.03,1.01,2.08,1.64,2.9-.07,4.47,3.65,6.77,8.21,8.02l10.97-8.35c.36,1.64-1.18,2.73-2.2,3.91l-9.71,11.1,4.61,1.35c3.7,1.08,8.18,2.75,10.34,6.05l-.36,2.39c-.06.4.49,1.36,1.46,1.38l6.18.11c3.56.07.69-6.24,2.89-6.37.86-.05,1.39.79,2.14,1.2,1.8.99,4.18.99,5.28,3.2l-2.02,2.29c1.23,1.55,1.56,3.06,3.11,3.07,2.87.03,5.44.73,8.31.41,1.37-.15,2.57.04,3.93.64l2.32-11.58,3.37-26.93.54,21.41,1.24,12.07c.12,1.21.65,2.27,1.58,2.63.81.32,1.92.64,3.09,0,.86,1.82,2.33,2.14,4.12,2.65,1.47.42,3.22,2.04,5.2.67,5.6-3.9,8.36-11.52,13.89-10.9l3.55-5.37c-.47-1.25-1.34-2.21-1.35-3.52l5.97-.64.85,18.06-.17,3.08c.67-.03,1.46-.09,2.22.11l-1.57,4.51-.4,1.16-.74-.15-9.48-.44-13.17-.04c-5.51-.02-10.81.2-15.91-2.37-4.17,3.3-8.82,3.85-13.75,4.04l-11.57.44c-4.22.16-8-1.47-12.1-2.44l-9.23-2.18-2.37-2.53-1-1.14c-.19-.22-.42.16-1.49,0l-2.88,3.29c-1.64,3.38-4.27,3.48-7.57,2.93l-8.76-1.47c-1.06-.18-1.68-1.56-2.63-2.24l-.25-10.04c-.09-3.55-2.33-6.92-2.12-10.04l.33-4.87,1.13-9.81c.44-3.77.39-7.85,0-11.53l-.45-4.37c-.88-2.66-.01-5.19,1.15-7.52l3.1-6.18c.48,0,.8.42,1.38.27Z"/>
|
||||||
|
<path class="cls-3" d="M219.05,227.87l.79.78-.21.25.6.14-.13.88-1.61,30.29-6.75,7.18,6.67,3.15,1.66,35.78c.09,1.99,0,4.05-1.49,5-.19.12-.4.28-.59.29l.1.11-.1-.11-3.17.11c-.29-.45-.37-1.65-1.08-1.62l-5.37.21c-2.72.1-5.69.2-8.32.01,1.39-2.73,2.38-6.36,4.66-8.69,6.53-4.89,10.39-12.17,7.22-20.39-.97-2.52-1.28-5.32,2-7.19-2.53-3.36-6.25-4.51-10.11-3.3l-31.72-.18,24.02-4.56,9.59-1.55,7.95-3.92c-1.51-4.02-.8-7.7,1.18-11.55l-2.14-4.34c-2.26.84-3.21-1-4.4-1.82-3.04-2.1-3.3-4.8-3.93-7.95-.28-1.42-3.04-2.89-2.3-4.95l3.85-.57,8.82.23c.74-.32,1.1.42,1.09-.02-.02-.58.56-1.21.18-1.42l2.43.46.62-.74Z"/>
|
||||||
|
<path class="cls-3" d="M188.5,197.92c-.14-.14-.26-.35-.42-.36.16.02.28.22.42.36l-.8,2.07.09,1.09-1.96.21c.42,5.17.06,10.03-.5,14.87l-1.28-.36c-1.42-1.53-3.13-3.22-5.23-2.46-1.55.57-3.44,1.37-4.18,3.47-2.84-4.2-5.79-4.07-5.91-4.7l-.47-2.59-2.94-4.65c-.61-2.29-3.19-3.03-5.2-2.41-.92,4.36-3.08,5.48-3.96,8.34-.45,1.46-2.64.96-3.8.95-2.51,0-1.87-2.85-5.82-4.74-.37-1.66.1-3.32.01-4.91-.66.27-1.08-.08-1.86-.21l.39,15.14-.69,6.77.31,26.42-5.43-30.42c-.91-5.11-1.2-10.05-4.36-14.26l-2.73-6.11,1.53.32c.22.05.68-.6.86-1.01,2.07,1.91,4.3,4.1,6.23,6.93l3.69-7.11,37.01-2.68c2.54-1.25,5.05-.41,6.58,1.69.16.02.28.22.42.36Z"/>
|
||||||
|
<path class="cls-6" d="M217.99,311.6l-.39.42-33.59,33.88-.77.03,1.57-4.51c2.75-7.94,3.11-16.57,1.63-25.15-.21-1.2,1.02-2.43,2.16-2.43l11.01.06c5.33.03,10.37-.9,15.23-2.19l3.17-.11Z"/>
|
||||||
|
<path class="cls-3" d="M219.05,227.87l-.62.74-2.43-.46c-8.06-2.53-16.09-3.31-24.73-2.08-1.96.28-2.28-4.19-1.56-5.38.71-6.92-.38-13.29-1.92-19.6l-.09-1.09.8-2.07c-.14-.14-.26-.35-.42-.36.16.02.28.22.42.36l30.55,29.94Z"/>
|
||||||
|
<path class="cls-2" d="M101.08,269.43l.04,3.58-18.21,3.46-8.46,2.25c-.58.15-.9-.27-1.38-.27l2.44-3.67c-.82-2.73-4.33-4.52-4.66-7.18,2.83-.32,5.42,1.61,8.52,1.76l11.02.53,10.69-.46Z"/>
|
||||||
|
<path class="cls-1" d="M101.08,269.43l.14-17.59-2.21.86c-.67.26-.75-.52-1.58-1.28l-1.82-.27c-2.52-3.62,10.1-13.15,23.9-11.48,5.96.72,11.76,4.34,13.78,10.33s-1.45,11.51-6.06,15.48c7.92.96,14.11,6.22,15.99,13.65,1.56,6.15-1.18,12.46-5.44,17.11-9.67,10.56-26.68,10.76-40.19,6.04-1.04-.36.15-2.48.78-2.4l2.57.33c.61.08,1.03.52,1.29.89.15.22.49-.51.55-.77.11-.47-7.17-2.29-6.46-4.96.11-.41.64-1.28,1.26-1.11l4.07,1.09-.55-22.34-.04-3.58ZM124.8,255.68c1.58-3.1.42-5.63-2.19-7.08-3.93-2.19-8.3-2.63-13.25-1.34v16.49c0,.62.35,1.06.86,1.37.26.15.45-.22,1.12-.47,5.49-1.09,10.87-3.89,13.45-8.97ZM106.69,248.96l-1.41-.03.17,8.81c.23-.03.44-.47.56-.3l.68-8.48ZM129.95,257.42c.65-1.58.8-4.23,1.16-5.86-1.76-.16-.51-1.16-.52-1.51,0-.22-.65-.01-1.42-.09,1.09,3.53.8,7.12-1.99,10.11,1.03.81,1.77-.21,1.99-.75l.77-1.9ZM133.96,284.81c.89-6.65-3.22-11.63-9.46-12.87-5.07-1.01-9.78.28-14.97,1.72l.7,23.53c5.81,1,11.07-.12,16.57-2.3,3.82-1.51,6.61-6.02,7.16-10.08ZM105.85,278.54c-.96,2.12-1.03,4.41.28,6.27.22-1.99.88-3.84-.28-6.27ZM138.89,279.18h-1.19s.2,6.6.2,6.6c.2-.03.41-.45.51-.3.97-1.95.1-4,.47-6.3Z"/>
|
||||||
|
<path class="cls-4" d="M185.54,293.84c3.32-.08,3.73-5.67,5.28-6.8-.13.1,2.68-.43,2.68.3,0,.32.14.92-.04,1.41-4.02,10.58-14.94,16.69-26.13,15.27-15.88-2.03-22.13-20.3-19.18-34.63,1.8-8.74,5.06-17.4,11.29-23.96,4.45-4.69,10.12-7.43,16.42-6.15,5.78,1.17,11.5,4.93,13.12,11.04.38,1.46.9,3.42-.63,4.33l-2.51-3.38c-.3,2.04.6,4.12-.33,5.64-4.24,4.25-1.78-5.98-8.6-8.12-11.98-3.75-22.45,16.87-18.87,32.85,1.02,4.54,2.77,8.58,6.15,11.09,3.68,2.73,8.49,3.43,12.68,1.81,8.32-3.22,9.32-11.28,11.7-9.93s-.59,5.41-3.02,9.23Z"/>
|
||||||
|
<path class="cls-2" d="M187.78,201.08c1.54,6.31,2.64,12.68,1.92,19.6-.72,1.2-.4,5.66,1.56,5.38,8.64-1.23,16.67-.45,24.73,2.08.38.2-.2.83-.18,1.42.02.44-.35-.31-1.09.02l-8.82-.23-3.85.57c-1.54-.27-2.98.32-4.27.46l-12.32,1.33c-1.29-.51-1.5-3.9-1.34-5.26l1.2-10.28c.57-4.84.93-9.7.5-14.87l1.96-.21Z"/>
|
||||||
|
<path class="cls-2" d="M200.05,310.31c2.64.19,5.6.09,8.32-.01l5.37-.21c.71-.03.79,1.17,1.08,1.62-4.86,1.29-9.9,2.22-15.23,2.19l-11.01-.06c-1.13,0-2.36,1.22-2.16,2.43,1.48,8.57,1.13,17.21-1.63,25.15-.76-.2-1.55-.15-2.22-.11l.17-3.08-.85-18.06c-.2-4.21.03-9.88,1.2-10.28l1.98-.67,14.97,1.08Z"/>
|
||||||
|
<path class="cls-6" d="M133.96,284.81c-.55,4.07-3.34,8.57-7.16,10.08-5.5,2.17-10.76,3.29-16.57,2.3l-.7-23.53c5.2-1.44,9.9-2.72,14.97-1.72,6.24,1.24,10.35,6.22,9.46,12.87Z"/>
|
||||||
|
<path class="cls-6" d="M124.8,255.68c-2.58,5.08-7.97,7.88-13.45,8.97-.67.25-.86.62-1.12.47-.51-.3-.87-.74-.87-1.37v-16.49c4.96-1.3,9.32-.85,13.25,1.34,2.61,1.45,3.76,3.98,2.19,7.08Z"/>
|
||||||
|
<path class="cls-2" d="M129.95,257.42l-.77,1.9c-.22.54-.96,1.56-1.99.75,2.79-3,3.08-6.58,1.99-10.11.77.08,1.42-.13,1.42.09,0,.36-1.24,1.36.52,1.51-.36,1.63-.51,4.27-1.16,5.86Z"/>
|
||||||
|
<path class="cls-6" d="M106.69,248.96l-.68,8.48c-.13-.17-.33.27-.56.3l-.17-8.81,1.41.03Z"/>
|
||||||
|
<path class="cls-2" d="M138.89,279.18c-.37,2.3.5,4.35-.47,6.3-.1-.15-.32.27-.51.3l-.2-6.6h1.19Z"/>
|
||||||
|
<path class="cls-2" d="M105.85,278.54c1.16,2.43.51,4.28.28,6.27-1.31-1.86-1.24-4.15-.28-6.27Z"/>
|
||||||
|
<path class="cls-5" d="M220.24,229.03l-.6-.14-1.21-.28-2.43-.46c-8.06-2.53-16.09-3.31-24.73-2.08-1.96.28-2.28-4.19-1.56-5.38.71-6.92-.38-13.29-1.92-19.6l-.09-1.09"/>
|
||||||
|
<path class="cls-5" d="M76.87,236.81c.03-2.33-2.37-3.38-4.36-4.69l-.32-6.53c-5.46-9.7-.92-17.59-1.51-28.43l16.23,2.03,23.56-.02,16.48-1.23c2.97-.22,5.24-1.39,7.61.8,2.07,1.91,4.3,4.1,6.23,6.93l3.69-7.11,37.01-2.68c2.54-1.25,5.05-.41,6.58,1.69.16.02.28.22.42.36l30.55,29.94.79.78.4.39"/>
|
||||||
|
<path class="cls-5" d="M220.24,229.03l-.13.88-1.61,30.29-6.75,7.18,6.67,3.15,1.66,35.78c.09,1.99,0,4.05-1.49,5l-.49.39-.5.31-33.59,33.88-1.17,1.19"/>
|
||||||
|
<path class="cls-5" d="M182.84,347.08l-.74-.15-9.48-.44-13.17-.04c-5.51-.02-10.81.2-15.91-2.37-4.17,3.3-8.82,3.85-13.75,4.04l-11.57.44c-4.22.16-8-1.47-12.1-2.44l-9.23-2.18-2.37-2.53"/>
|
||||||
|
<path class="cls-5" d="M182.84,347.08l.4-1.16,1.57-4.51c2.75-7.94,3.11-16.57,1.63-25.15-.21-1.2,1.02-2.43,2.16-2.43l11.01.06c5.33.03,10.37-.9,15.23-2.19l3.17-.11"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #006d30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #00873e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3a160a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #3d180d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #00a04b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-6" d="M153.66,195.96l-.46-.48-2.25.71,3.07,18.95.16,18.83c.02,1.82-1.11,3.58.04,5.52.86-1.54,1.21-3.1,1.51-4.81l4.63-26.09c.51-2.89.63-6.32-1.68-7.91,3.44-.33,1.92-4.68,4.63-6.02,1.06-.53,2.58-.34,3.85-.57l.57-.11.09,1.97c-1.23.02-2.43-.35-3.03.56,1.72-.21,4.69,1.64,3.78,3.07-1.78,2.8-2.55,5.21-1.44,8.53.2.59-.73,1.95-.05,2.92.42.6,1.37.66,2.45,1.35l.89,1.5c.22.37,1.97.27,1.95-.45-.06-2.39-4.42-4.98-2.14-5.98.89-.39,1.59-.33,2.08.47l3.78,6.21,3.2-1.66,6.99.65c-.45,5.85-2.86,9.72.48,15.56l8.02-1.38c4.46-.77,3.87,5.08,6.83,6.44-.37,2.19.28,4.05,1.64,6.45-6.86,3.73-8.55.88-9.78,1.54-2.84,1.52-2.36,6.78,0,8.07,2.28-5.08,5.7-2.99,8.83-5.88l3.1,3.95c.13.5-.52.62-.77.88-.49.54-1.76-.53-2.23-.28-1.18.63-.37,1.39-.08,1.77.39.5,2.4.95,4.35,1.17.61,1.74-1.58,2.73-2.4,3.52.4,2.18,3.63,1.16,5.22,1.12,1.87-.05,3.1,2.33,5.08,1.26l-.3,8.25c-7.48,2.34-14.03,3.49-21.14,4.16l-23.27,2.2c.09.33.06.66.03.83.7.61,1.85-.06,2.86-.16l30.5,2.09c2.57.18,5.12.05,7.54,1.41-6.47,3.24-11.29,0-12.25.87-.59.54-1.48,2.27-.13,2.72.91.3,1.87.35,2.3.69,1.2.95-1.2,5.83,1.12,6.74.78.3,2.4-.64,2.93.56.31.71.23,2.04-.94,2.69l-1.39,1.83c-1.39.83-1.28,1.3-1.07,2.69.24,1.57,1.51,1.36,3.53.89v3.28s4.12.29,4.12.29l.1,2.95c-4.9.84-9.59,2.46-12.77,6.65l-8.81-1.19c-7.44-1-2.19,11.29-2.46,19.21l-4.89-.16c-2.63-.09-6.35,0-7.58-2.4,1.86-2.75-1.3-5.62-.12-9.66-1.36-.25-2.17-1.48-3.52-1.43-2.58,1.83-5.92,2.68-6.55,5.79-.14.7,1.35,1.6.78,2.39-.88,1.22-5.4.29-5.27,3.28.14,3.32,3.47,1.65,4.91,3.68-1.55,1.93-3.69,4.09-5.51,5.41-.67.04-2.1-.08-2.75-.12l-7.79-.48-1.91-10.68-.87-20.54c-.02-.38.98-.89.38-1.07-.21-.06-.37-.3-.58-.52-.15-.16-.64.62-.67.95l-1.71,16.74c-.72,7.06-2.02,13.71-4.34,20.67-4.06.6-8.21-.87-12.16-2.67-.9-.41-2.41-.75-2.22-2.2.46-.15,1.4-.34,1.49-.82.06-.36.32-1.32-.43-1.33l-9.76-.09c-.43,0-.7-1.15-.36-1.47.61-.58,1.76-.61,2.03-1.46.31-.96.25-1.75.13-3.16l-14.56.05c-3.39.01-6.59.16-10.04-.64l12.59-14.11c-.78-.51-1.89.32-2.63,1.01-4.11,3.83-8.08,7.52-13.71,8.92.38-1.11,1.48-1.61,2.04-2.49l-1.39-4.29c-2.64.06-2.8-2.85-3.9-3.74-2.66-2.14-5.75-3.87-5.86-7.47-.13-4.69,5.58-4.68,4.87-7.46-1.89-.09-2.79-.22-3.53-1.67-2.76-5.4-5.55-10.74-6.13-16.95l.26-3.84c1.78-1.26,3.89-1.22,6.04-1.61l19.34-3.5c-7.89-1.44-15.42-1.63-22.88-4.1l-3.59-.27c-1.87-2.75-1.64-5.92-2.07-9l-1.2-8.62-.43-6.7c-.53-8.33-.99-6.87,3.53-10.65l3.35-2.87.41.38-.41-.38-6.25-5.77,1.99-24.45,29.01,2.92c.3,2.58-.26,4.92.22,7,.52-.09,1.76-.17,2.02.58.15.44.4.77.33,1.31l3.48,3.71c1.73,1.85,4.57,3.61,5.86,5.97,1.68,3.06,3.62,8.54,4.74,8.09.28-.11.94-.29,1.08-1.02.16-.85-.78-2.14-.85-3.37l-.49-9.68c-1.06-2.47-3.15-4.98-2.29-7.94l7.42-2.99.74-.79c.25-.26.12-.53-.1-.59-.25-.07-.86-.46-1.49.09l-.35-1.99,28.3-2.23c1.58-.12,3.1-.91,3.9,1l.46.48ZM186.99,284.59c.05-.8-.21-2.93-.89-3.46-1.03-.8-2.09-.08-2.67.96-2.92,5.19-8.02,9.43-14.12,9.6-10.92.3-16.23-12.43-14.77-24.11,1.5-12,9.42-26.8,19.63-23.04,6.4,2.36,4.16,10.81,8.14,8.64,1.03-.56,1.08-3.75.59-6.65,1.56,1.07,1.13,2.55,1.92,3.73.35.51,1.07.12,1.41-.26,1.54-1.69-1.42-11.67-11.3-14.47-18.44-5.21-31.37,21.26-30.31,39.87.46,8.02,4.55,18.71,12.73,22.74,12.45,6.13,27.46.08,33.05-12.33.29-.63.45-1.54.17-1.99s-.96-1.1-1.65-.81c-.59.25-1.21.83-1.92,1.58ZM101.07,245.27c-.97-.28-1.36,2.51-.27,3.04.84.41,1.82.3,3.43.02l-.02,15.84.52,28.42c.02,1.26-3.05.8-3.99,2.17-.37.54-.43,1.53.14,1.9.48.31,1.1.34,2.1.38l1.74.07c.21,0-.06,1.21-.78.9-1.53-.66-2.37,1.04-2.06,2.1.54,1.85,3.63,1.46,6.98.54.54.72.85,2.18,1.64,2.22.74.04,1.45-.09,1.73-.57.43-.71.32-1.49.26-2.52l13.46-1.99,15.07-1.37c.5-.05,1.21-.93,1.18-1.32-.02-.33-.33-.94-.83-.93l-6.64.11v-.92s7.21-.58,7.21-.58c.67-.05,1.07-1,1.09-1.41s-.74-1.12-1.09-1.32l-2.18-1.26c-7.01-.15-13.57.87-20.29,1.77l-6.33.85-.28-18.72c1.4-.01,2.1-.11,2.8-.21l14.99-2.18c1.84-.27,2.77-2.22,2.67-4.08-1.51-.62-2.4-.02-3.68-.25.35-1.33,1.22-1.8.93-2.28-.46-.75-.97-1.35-1.81-1.22l-16.69,2.55v-17.6c8.76-1.96,17.15-2.99,25.82-3.25.13,0,3.38-2.78,2.38-4.14-.56-.77-2.05-.63-3.18-.4-.1-1.28.99-1.47.68-1.91-.6-.86-1.04-1.38-1.97-1.52-8.39.05-16.34,1.7-24.78,2.69-.47-.45-.8-1.64-1.29-1.61-.68.04-.97.86-1.59,1.72-.59-.64-1.13-1.26-1.75-1.19-.85.11-1.69,0-1.57,1.15.13,1.23-2,1.2-2.96,1.52l-2.84.93c-1.36.44-1.02,1.83-.51,2.86l5.73-.22-1.66.49c-.18.57-.19,1.1-1.51.72Z"/>
|
||||||
|
<path class="cls-2" d="M189.09,192.96c.05.24.06.17.06-.01l.12.59,1.48,13.12.05,15.42c9.75-1.12,18.49.18,27.17,2.92l.66-.85,1.15,1.22.02.65-.12,6.48c-.06,3.28-.1,6.15-.34,9.4l-1.49,20.28c-.14,1.92.53,4.27-.65,6.12l-5.41,3.97,6.02,6.36,1.49,21.56c.37,5.41,2.59,8.94-3.33,12.4.34.27.29,1.3.03,1.53s-.16.34-.97.66c-.37,1.66-2.22,1.95-3.2,2.95l-20.55,20.92c-1.09,1.11-1.62,2.3-2.99,2.91-1.44.64-2.89,1.38-4.34,2.88l-11.83-.23-21.18-.57c-2.81-.08-5.69-.28-7.16-3.49l-2.05-.16c-3.58,7.25-10.93,5.75-18.03,6.21-3.56.23-7.24,1.2-10.75.28l-13.96-3.64c-3.32-.87-4.74-4.5-6.28-4.44-1.22.05-1.49,1.13-2.18,1.92l-3.06,3.53c-1.81,2.08-4.75,1.11-7.01.74l-7.91-1.31c-.88-.14-1.36-1.64-2.36-2.17.88-6.23-.67-11.82-2.56-17.57-.32-.99.84-5.27,1.01-8.28l.29-4.88.53-6.71.52-27.67,1.82-3.79c.68.02,1.42.1,2.03.75l-.26,3.84c.58,6.2,3.37,11.55,6.13,16.95.74,1.45,1.64,1.58,3.53,1.67.71,2.78-5.01,2.77-4.87,7.46.1,3.59,3.19,5.33,5.86,7.47,1.1.88,1.26,3.8,3.9,3.74l1.39,4.29c-.55.88-1.66,1.38-2.04,2.49,5.64-1.4,9.6-5.08,13.71-8.92.74-.69,1.84-1.52,2.63-1.01l-12.59,14.11c3.44.8,6.65.65,10.04.64l14.56-.05c.11,1.41.17,2.2-.13,3.16-.27.85-1.42.88-2.03,1.46-.33.32-.07,1.46.36,1.47l9.76.09c.76,0,.5.97.43,1.33-.08.47-1.03.66-1.49.82-.2,1.44,1.31,1.79,2.22,2.2,3.95,1.79,8.1,3.27,12.16,2.67,2.32-6.96,3.62-13.61,4.34-20.67l1.71-16.74c.03-.33.52-1.11.67-.95.21.23.36.46.58.52.6.17-.4.69-.38,1.07l.87,20.54,1.91,10.68,7.79.48c.64.04,2.08.16,2.75.12,1.82-1.32,3.96-3.47,5.51-5.41-1.45-2.03-4.77-.36-4.91-3.68-.13-2.98,4.38-2.06,5.27-3.28.57-.79-.92-1.69-.78-2.39.63-3.11,3.98-3.96,6.55-5.79,1.36-.05,2.16,1.19,3.52,1.43-1.18,4.04,1.98,6.9.12,9.66,1.23,2.39,4.95,2.31,7.58,2.4l4.89.16c.28-7.91-4.98-20.21,2.46-19.21l8.81,1.19c3.18-4.19,7.87-5.81,12.77-6.65l-.1-2.95-4.12-.29v-3.28c-2.02.46-3.28.68-3.52-.89-.21-1.38-.32-1.86,1.07-2.69l1.39-1.83c1.17-.65,1.25-1.97.94-2.69-.53-1.2-2.15-.26-2.93-.56-2.32-.9.09-5.78-1.12-6.74-.43-.34-1.39-.39-2.3-.69-1.35-.45-.46-2.18.13-2.72.95-.87,5.78,2.37,12.25-.87-2.42-1.36-4.97-1.23-7.54-1.41l-30.5-2.09c-1,.11-2.15.77-2.86.16.03-.17.07-.51-.03-.83l23.27-2.2c7.11-.67,13.66-1.83,21.14-4.16l.3-8.25c-1.98,1.07-3.21-1.31-5.08-1.26-1.6.04-4.83,1.06-5.22-1.12.82-.79,3.01-1.78,2.4-3.52-1.94-.22-3.96-.67-4.35-1.17-.3-.39-1.1-1.14.08-1.77.47-.25,1.75.82,2.23.28.24-.27.9-.39.77-.88l-3.1-3.95c-3.13,2.9-6.55.8-8.83,5.88-2.36-1.3-2.84-6.55,0-8.07,1.24-.66,2.93,2.18,9.78-1.54-1.36-2.4-2.01-4.25-1.64-6.45-2.96-1.36-2.37-7.21-6.83-6.44l-8.02,1.38c-3.34-5.83-.94-9.71-.48-15.56l-6.99-.65-3.2,1.66-3.78-6.21c-.48-.79-1.18-.86-2.08-.47-2.29,1,2.08,3.59,2.14,5.98.02.72-1.73.82-1.95.45l-.89-1.5c-1.07-.69-2.02-.75-2.45-1.35-.68-.97.25-2.32.05-2.92-1.11-3.33-.34-5.73,1.44-8.53.91-1.43-2.05-3.28-3.78-3.07.6-.91,1.8-.54,3.03-.56l-.09-1.97-.57.11.57-.11,13.46-1.04c2.81-.22,5.44-.4,7.96,0,0,.25,0,.18-.06.01Z"/>
|
||||||
|
<path class="cls-6" d="M189.09,192.96c.13-.05.29.17.44.4l29.1,30.8-.66.85c-8.68-2.74-17.43-4.04-27.17-2.92l-.05-15.42-1.48-13.12-.12-.59c0,.18-.01.25-.06.01Z"/>
|
||||||
|
<path class="cls-2" d="M121,196.71l.35,1.99c.63-.55,1.25-.16,1.49-.09.22.06.34.33.1.59l-.74.79-7.42,2.99c-.86,2.97,1.23,5.48,2.29,7.94l.49,9.68c.06,1.23,1,2.52.85,3.37-.14.73-.8.91-1.08,1.02-1.11.45-3.05-5.03-4.74-8.09-1.29-2.35-4.13-4.12-5.86-5.97l-3.48-3.71c.07-.54-.17-.88-.33-1.31-.26-.75-1.5-.67-2.02-.58-.48-2.08.07-4.42-.22-7,3.74.38,6.19,6.49,8.76,5.54,2.12-.78.47-6.55,7.47-6.94l4.09-.23Z"/>
|
||||||
|
<path class="cls-1" d="M153.66,195.96c1.59,1.69,3.16,3.44,5.02,4.72,2.32,1.59,2.2,5.02,1.68,7.91l-4.63,26.09c-.3,1.71-.65,3.27-1.51,4.81-1.16-1.94-.03-3.7-.04-5.52l-.16-18.83-3.07-18.95,2.25-.71.46.48Z"/>
|
||||||
|
<path class="cls-1" d="M73.83,272.96c-.61-.65-1.35-.72-2.03-.75l3.58-4.84-2.64-3.89,3.59.27c7.46,2.46,14.99,2.66,22.88,4.1l-19.34,3.5c-2.15.39-4.26.34-6.04,1.61Z"/>
|
||||||
|
<path class="cls-4" d="M101.07,245.27c1.32.37,1.33-.16,1.51-.72l1.66-.49-5.73.22c-.51-1.03-.85-2.41.51-2.86l2.84-.93c.97-.32,3.09-.29,2.96-1.52-.12-1.15.72-1.05,1.57-1.15.61-.08,1.16.55,1.75,1.19.62-.86.91-1.67,1.59-1.72.49-.03.82,1.16,1.29,1.61,8.43-1,16.39-2.64,24.78-2.69.93.14,1.37.67,1.97,1.52.31.44-.79.63-.68,1.91,1.13-.23,2.61-.37,3.18.4,1,1.35-2.25,4.13-2.38,4.14-8.67.26-17.05,1.29-25.81,3.25v17.6s16.68-2.55,16.68-2.55c.84-.13,1.36.47,1.81,1.22.29.47-.58.95-.93,2.28,1.28.22,2.17-.38,3.68.25.1,1.86-.83,3.81-2.67,4.08l-14.99,2.18c-.7.1-1.4.2-2.8.21l.28,18.72,6.33-.85c6.72-.9,13.28-1.91,20.29-1.77l2.18,1.26c.35.2,1.11.91,1.09,1.32s-.42,1.36-1.09,1.41l-7.21.58v.92s6.65-.11,6.65-.11c.5,0,.81.59.83.93.02.4-.69,1.28-1.18,1.32l-15.07,1.37-13.46,1.99c.07,1.03.17,1.8-.26,2.52-.29.48-.99.61-1.73.57-.79-.04-1.1-1.5-1.64-2.22-3.36.92-6.44,1.31-6.98-.54-.31-1.06.53-2.77,2.06-2.1.72.31,1-.89.78-.9l-1.74-.07c-.99-.04-1.62-.08-2.1-.38-.57-.37-.51-1.36-.14-1.9.94-1.37,4.01-.91,3.99-2.17l-.52-28.42.02-15.84c-1.61.28-2.59.39-3.43-.02-1.08-.53-.7-3.32.27-3.04Z"/>
|
||||||
|
<path class="cls-5" d="M186.99,284.59c.71-.75,1.33-1.33,1.92-1.58.69-.29,1.36.35,1.65.81s.12,1.36-.17,1.99c-5.59,12.41-20.6,18.47-33.05,12.33-8.18-4.03-12.28-14.72-12.73-22.74-1.06-18.61,11.87-45.08,30.31-39.87,9.88,2.79,12.84,12.78,11.3,14.47-.34.38-1.07.77-1.41.26-.8-1.18-.36-2.66-1.92-3.73.49,2.89.44,6.09-.59,6.65-3.98,2.17-1.74-6.28-8.14-8.64-10.22-3.76-18.14,11.04-19.63,23.04-1.45,11.68,3.85,24.41,14.77,24.11,6.1-.17,11.2-4.41,14.12-9.6.59-1.04,1.65-1.76,2.67-.96.68.53.94,2.66.89,3.46-.24,0-.82-.26-1.15.32l-2.56,4.45.51.54c1.45-1.23,3.14-4.22,3.13-4.06l.07-1.24Z"/>
|
||||||
|
<path class="cls-1" d="M186.99,284.59l-.07,1.24c0-.16-1.68,2.83-3.13,4.06l-.51-.54,2.56-4.45c.33-.57.91-.32,1.15-.32Z"/>
|
||||||
|
<path class="cls-3" d="M219.78,225.37l-1.81-.37c-8.68-2.74-17.43-4.04-27.17-2.92l-.05-15.42-1.48-13.12-.19-.58"/>
|
||||||
|
<path class="cls-3" d="M219.78,225.37l.02.65-.12,6.48c-.06,3.28-.1,6.15-.34,9.4l-1.49,20.28c-.14,1.92.53,4.27-.65,6.12l-5.41,3.97,6.02,6.36,1.49,21.56c.37,5.41,2.59,8.94-3.33,12.4-7.99.64-14.33,1.69-23.27.55-5.4-.69-.74,10.29-4.65,22.7-.68,2.15-.83,4.01.26,5.73"/>
|
||||||
|
<path class="cls-3" d="M189.15,192.95c-2.52-.41-5.15-.22-7.96,0l-13.46,1.04-.57.11c-1.27.24-2.79.05-3.85.57-2.71,1.35-1.19,5.7-4.63,6.02-1.86-1.28-3.42-3.03-5.02-4.72l-.46-.48c-.8-1.91-2.32-1.12-3.9-1l-28.3,2.23-4.09.23c-7.01.39-5.35,6.16-7.47,6.94-2.57.94-5.02-5.17-8.76-5.54l-29.01-2.92-1.99,24.45,6.25,5.77.41.38.41.37"/>
|
||||||
|
<polyline class="cls-3" points="189.15 192.95 189.53 193.36 218.63 224.15 219.78 225.37"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #39170a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #3d180b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #a88a21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #d3ac2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #ffcf34;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-6" d="M179.77,205.02l.77,4.04c.04.21.1.45.08.59.02-.14-.04-.38-.08-.59-.85,1.19-1.1-.58-2.67-.25l.79,8.42-.89,10.4c-.41,4.82-1.24,9.97,1.41,14.45,1.05,1.78,9.63-.31,17.16-.75,1.02,1.74,4.84.68,6.63,4.83.88,2.04,2.91,3.45,3.86,5.38,1.4,2.81,2.25,5.73,4.84,7.45,1.02-.82,1.88-.07,2.66-.28l.07,6.27c.03,2.58.2,4.71-.47,6.93-.75.09-2.1-.23-2.94.31-2.87,1.83-6.08,2.33-9.57,2.78l-6.5.84-16.99.61c-3.66.13-7.28-.69-11.12,1.38l16.47,2.61,22.92,3.07,7.37.46c.9,1.94.62,4.11.83,6.29l1.64,17c.53,5.45-8.08,6.66-5.27,8.93,2.01,1.62,5.55,12.81,5.16,16.41l-2.13,19.73c-8.18,1.27-16.4,2.09-24.72,1.88l-6.37-8.74c-.46-.63-1.12-1.06-1.64-1.08-.67-.03-1.56.51-2.14,1.26l-5.23,6.74-16.53,1.49c-2.64.24-5.37.61-7.42-.64l-.56-.34c.37,0,.52-.6.99-.96.23-.18-.52-.27-.45-.87l-1.23-17.67c-.63-9.07-.23-17.95-2.92-26.95l-2.35,17.78-4.21,27.18,3.26.18c-1.16,2.56-3.95,2.42-6.56,2.51l-13.67.49-16.87,1.08c-.05-.45-.49-.46-.65-.67l-.4-.38-.4-.38-.4-.38.58-.54.2.56c2.01-.21.19-3.36.22-9.43,0-2.13-.72-4.28.05-6.44.35-.98,2.07-.69,2.87-.56.43-1.64-1.95-1.67-2.13-2.33l.95-10.42c.09-.94.1-1.71-.31-2.02-1.64-1.25-3.51.15-5.41-.22.06-.7-.29-1.6-.74-2.1-.64-.71-1.77-.71-2.91-.62l-12.92,1.07.18-1.34s-.25-1-.63-1.37l-3.78-3.63c-1.39-1.34-2.03-2.83-1.58-5.16l-3.26-3.67c-1.51-1.7-2.88-4.39-.81-6.29,1.1-.32,3.6.81,4.46-.47,2.86-4.24-1.31-5.58-.49-7.74,1.33-3.48-4.58-6-4.26-8.5l13.01-2.5c3.67-1.59,6.78-3.8,10.59-3.22l.1,32.81c0,1.01,1.11,1.93,1.68,2.01,2.03.27,1.28-3.03,2.08-6.24,2.13.46-1.02,4.51,1.57,8.01.45.61,1.46,0,1.84-.26.62-.41.57-1.41.77-2.39l1.09-.2c.09-.02.41-.44.4-.93l-.43-14.35-.45-23.53c-.01-.52-.02-1.19.34-1.48.58-.46,1.53-.32.75.63l8.86,15.58,13.62,24.51c.37.67,1.86,1.46,2.5,1.41.85-.07,1.58-1.73,1.05-2.73-.13-1.47.35-1.05,1.48-.63,1.09-.52,1.88.67,2.48.34.91-.51,1.18-1.31,1.03-2.48-1.7-13.24-2.12-26.26-1.41-39.55l.91-17.03c.05-.93-.07-1.64-.43-2.01-.49-.49-1.44-.78-1.91-.55-.75.36-1.15,1.14-1.13,2.02l.19,7.68h-.86s-.48-6.72-.48-6.72c-.08-1.09-1.4-1.77-2.14-1.88-1.31-.2-2.03,1.16-2.03,2.59l-.14,40.7c-1.18.02-.63-.49-.97-1.13-7.29-13.26-14.57-26.19-20.99-39.77-.21-.45-.86-.62-1.11-.66-.4-.05-.83.64-1.21.96l-.5-2.31c-.16-.75-1.63-1.47-2.45-.7-1.35,1.27-.5,3.3-.84,4.65-1.25-.1-.52-1.14-.93-1.47-.51-.4-1.14-.55-2.26-.76l-.37,3.69c-2.89-2.03-5.73-4.3-9.04-6.32-.72-.34-2.29.61-2.32,1.39-.03,1.15-.89,2.45.54,3.02l10.66,3.95.41,19.15-14.09-.14c-5.14-.17-9.74-2.39-14.67-4.07-.85.62-1.32,1.36-2.39.69l-1.67-18.92c-.18-2.03.78-3.84,2.65-4.71l7.42-3.44-5.93-5.13.16-5.46c-1.39-3.37-3.58-6.69-3.35-10.39l1.05-16.95c.05-.8-.56-1.46-.02-2.1s1.18.05,2.02.15l15.08,1.79,14.48.61,27.87-1.53c2.78-.15,4.85.21,6.45,2.28-.81,1.03-1.69-.01-3.26,0l4.13,7.93c4.58,11.64,3.97,31.83,8.32,46.37l-.45-23.47,1.02-30.81c-.29-.23-.77.13-1.3.05-.68-.1.4-.6.29-1.64l-.63.29.63-.29,16.21-1.53,9.43-.31c4.27-.14,8.52-1.66,12.53-.39l.66.23c-.03.16-.03.22-.03-.04ZM183.3,260.1c.11.72.67,3.38,2.19,2.35,1.76-7.21-5.95-14.22-14.14-15.14-14.41-1.63-23.52,15.84-26.38,29.41-2.46,11.72.92,27.85,11.7,33.33,3.81,1.94,8.55,3.2,12.6,2.71,17.38-2.11,23.73-18.78,19.2-17.57-1.69.45-1.96,3.32-2.63,3.58-2.37.93.71-2.54.04-4.79-.24-.81-1.17-1.31-1.71-.96-2.42,1.56-3.83,8.02-11.84,10.21-7.31,2.01-14.11-2.14-16.6-9.5-6.23-18.48,5.85-40.89,17.78-36.88,5.29,1.78,5.29,9.24,6.76,9.31,2.39.13,2.79-4.52,2.18-6.12l.85.05Z"/>
|
||||||
|
<path class="cls-5" d="M97.21,275.95c.18.35.62.79.03.98-3.81-.59-6.93,1.63-10.59,3.22l-13.01,2.5c-.32,2.5,5.59,5.02,4.26,8.5-.82,2.16,3.35,3.5.49,7.74-.86,1.28-3.37.15-4.46.47-2.07,1.9-.69,4.59.81,6.29l3.26,3.67c-.45,2.32.19,3.82,1.58,5.16l3.78,3.63c.38.37.64,1.34.63,1.37l-.18,1.34c-5.9.49-11.78-1.34-17.6-2.56,0,.16,0,.27,0,.02l-.69-.73c.06-1.46-.73-4.12-.11-5.67.88-2.2.34-3.61.48-5.56l1.71-23.37c.25-3.49-1.22-7.09-1.53-10.53,1.07.67,1.54-.07,2.39-.69,4.93,1.69,9.53,3.9,14.67,4.07l14.09.14Z"/>
|
||||||
|
<path class="cls-6" d="M83.8,320.81l12.92-1.07c1.13-.09,2.26-.09,2.91.62.45.5.8,1.4.74,2.1-.91,10.45-.01,20.93,3.56,30.87l-.58.54-36.74-35.2-.4-.41c5.82,1.22,11.7,3.05,17.6,2.56Z"/>
|
||||||
|
<path class="cls-6" d="M179.77,205.02c0,.18,0,.24.03.04l34.21,33.44-.49.54-1.92-.35c-.22-.04-.45-.09-.64-.11l-2.66-.27c-13.98-3.98-24.18.77-25.99-.65-.73-.57-.59-1.87-.82-2.93,1.42-8.61.58-16.85-.87-25.09.02-.14-.04-.38-.08-.59l-.77-4.04Z"/>
|
||||||
|
<path class="cls-5" d="M149.77,353.23l-.56-.34-3.01-2.1c-1.49-1.04-3.18-.66-3.91.8l-3.26-.18,4.21-27.18,2.35-17.78c2.69,9,2.29,17.87,2.92,26.95l1.23,17.67c-.07.59.69.68.45.87-.47.37-.62.97-.99.96l.56.34Z"/>
|
||||||
|
<path class="cls-5" d="M140.36,207.35l.63-.29c.11,1.04-.97,1.54-.29,1.64.53.08,1.01-.28,1.3-.05l-1.02,30.81.45,23.47c-4.34-14.54-3.74-34.72-8.32-46.37l-4.13-7.93c1.57-.02,2.45,1.02,3.26,0,1.15,1.48,3.05,3.31,5.03,3.99l3.08-5.27Z"/>
|
||||||
|
<path class="cls-5" d="M213.94,271.89c-1.02,3.42-5.64,5.2-4.81,6.04l3.77,3.83c.8.81.39,1.56.68,2.19l-7.37-.46-22.92-3.07-16.47-2.61c3.84-2.08,7.46-1.25,11.12-1.38l16.99-.61,6.5-.84c3.49-.45,6.7-.95,9.57-2.78.84-.53,2.19-.22,2.94-.31Z"/>
|
||||||
|
<path class="cls-5" d="M214.34,258.7c-.77.21-1.64-.54-2.66.28-2.59-1.73-3.44-4.65-4.84-7.45-.96-1.92-2.99-3.34-3.86-5.38-1.79-4.15-5.61-3.09-6.63-4.83l14.51-.85c-.03-.62-.15-1.25.1-1.89.19.02.43.07.64.11l1.92.35.49-.54.4.39c.14.14.38.23.4.39l.1.72c.48,3.66-.57,7.44-.57,11.31v7.39Z"/>
|
||||||
|
<path class="cls-1" d="M96.95,254.77l.37-3.69c1.12.21,1.75.37,2.26.76.42.33-.32,1.37.93,1.47.34-1.34-.52-3.38.84-4.65.82-.77,2.29-.05,2.45.7l.5,2.31c.37-.32.81-1.01,1.21-.96.25.03.9.2,1.11.66,6.43,13.58,13.7,26.51,20.99,39.77.33.64-.22,1.15.97,1.13l.14-40.7c0-1.44.72-2.79,2.03-2.59.75.11,2.07.79,2.14,1.88l.48,6.73h.86s-.19-7.69-.19-7.69c-.02-.88.38-1.66,1.13-2.02.47-.23,1.41.06,1.91.55.36.36.48,1.08.43,2.01l-.91,17.03c-.71,13.29-.29,26.3,1.41,39.55.15,1.17-.12,1.97-1.03,2.48-.6.34-1.39-.86-2.48-.34-1.13-.42-1.61-.84-1.48.63.53,1-.2,2.66-1.05,2.73-.63.05-2.13-.74-2.5-1.41l-13.62-24.51-8.86-15.58c.78-.95-.17-1.09-.75-.63-.36.29-.35.96-.34,1.48l.45,23.53.43,14.35c.01.49-.31.91-.4.93l-1.09.2c-.2.98-.16,1.98-.77,2.39-.38.26-1.39.87-1.84.26-2.59-3.5.56-7.55-1.57-8.01-.8,3.22-.05,6.51-2.08,6.24-.58-.08-1.68-1-1.68-2.01l-.1-32.81c.59-.19.15-.63-.03-.98l-.41-19.15c-.01-.67.09-1.35.16-2.03ZM131.61,294.87c.56.17.86.1,1.15.05l-.14-5.48h-.88s-.12,5.43-.12,5.43Z"/>
|
||||||
|
<path class="cls-3" d="M183.3,260.1l-.32-2.09c-.11-.72-1.11-1.15-.97-1.75l-3.04-3.05c-.51-.51-.92-.45-1.63-.42,2.53,1.87,4.03,4.39,5.11,7.26.61,1.6.21,6.25-2.18,6.12-1.47-.08-1.46-7.54-6.76-9.31-11.93-4-24.01,18.41-17.78,36.88,2.48,7.36,9.28,11.51,16.6,9.5,8-2.2,9.41-8.66,11.84-10.21.54-.35,1.47.15,1.71.96.66,2.24-2.41,5.72-.04,4.79.67-.26.94-3.13,2.63-3.58,4.53-1.21-1.83,15.46-19.2,17.57-4.05.49-8.79-.77-12.6-2.71-10.78-5.48-14.16-21.61-11.7-33.33,2.86-13.58,11.96-31.05,26.38-29.41,8.2.93,15.9,7.93,14.14,15.14-1.52,1.03-2.08-1.63-2.19-2.35Z"/>
|
||||||
|
<path class="cls-4" d="M180.62,209.65c1.45,8.24,2.29,16.48.87,25.09.23,1.06.09,2.35.82,2.93,1.81,1.42,12.02-3.33,25.99.65l2.66.27c.19.02.43.07.64.11-.22-.04-.45-.09-.64-.11-.26.64-.13,1.27-.1,1.89l-14.51.85c-7.53.44-16.11,2.54-17.16.75-2.65-4.48-1.82-9.62-1.41-14.45l.89-10.4-.79-8.42c1.56-.33,1.82,1.45,2.67.25.04.21.1.45.08.59Z"/>
|
||||||
|
<path class="cls-4" d="M100.37,322.45c1.9.37,3.77-1.03,5.41.22.41.31.4,1.07.31,2.02l-.95,10.42c.18.66,2.56.69,2.13,2.33-.8-.12-2.52-.42-2.87.56-.77,2.16-.04,4.31-.05,6.44-.03,6.07,1.79,9.23-.22,9.43l-.2-.56c-3.58-9.94-4.47-20.41-3.56-30.87Z"/>
|
||||||
|
<path class="cls-5" d="M96.95,254.77c-.07.68-.17,1.36-.16,2.03l-10.66-3.95c-1.43-.57-.57-1.87-.54-3.02.02-.78,1.6-1.73,2.32-1.39,3.31,2.02,6.15,4.3,9.04,6.32Z"/>
|
||||||
|
<path class="cls-4" d="M183.3,260.1l-.85-.05c-1.08-2.87-2.59-5.4-5.11-7.26.71-.03,1.12-.09,1.63.42l3.04,3.05c-.15.6.86,1.03.97,1.75l.32,2.09Z"/>
|
||||||
|
<path class="cls-4" d="M131.61,294.87l.12-5.43h.88s.14,5.47.14,5.47c-.29.05-.59.12-1.15-.05Z"/>
|
||||||
|
<path class="cls-2" d="M179.77,205.02l-.62-.2c-4-1.27-8.26.25-12.53.39l-9.43.31-16.21,1.53-.63.29-3.08,5.27c-1.99-.68-3.89-2.51-5.03-3.99-1.6-2.07-3.66-2.43-6.45-2.28l-27.87,1.53-14.48-.61-15.08-1.79c-.84-.1-1.46-.81-2.02-.15s.07,1.3.02,2.1l-1.05,16.95c-.23,3.7,1.96,7.02,3.35,10.39l-.16,5.46,5.93,5.13-7.42,3.44c-1.86.87-2.83,2.67-2.65,4.71l1.67,18.92c.3,3.43,1.78,7.04,1.53,10.53l-1.71,23.37c-.14,1.96.39,3.36-.48,5.56-.62,1.55.17,4.21.11,5.67l.69.73"/>
|
||||||
|
<path class="cls-2" d="M214.8,239.27l-1.28-.24-1.92-.35c-.22-.04-.45-.09-.64-.11l-2.66-.27c-13.98-3.98-24.18.77-25.99-.65-.73-.57-.59-1.87-.82-2.93,1.42-8.61.58-16.85-.87-25.09.02-.14-.04-.38-.08-.59l-.77-4.04"/>
|
||||||
|
<path class="cls-2" d="M104.54,355.01l-.41-1.13-.2-.56c-3.58-9.94-4.47-20.41-3.56-30.87.06-.7-.29-1.6-.74-2.1-.64-.71-1.77-.71-2.91-.62l-12.92,1.07c-5.9.49-11.78-1.34-17.6-2.56"/>
|
||||||
|
<polyline class="cls-2" points="179.8 205.06 214.01 238.49 214.41 238.88 214.8 239.27"/>
|
||||||
|
<path class="cls-2" d="M214.8,239.27l.1.72c.48,3.66-.57,7.44-.57,11.31v7.39s.07,6.27.07,6.27c.03,2.58.2,4.71-.47,6.93-1.02,3.42-5.64,5.2-4.81,6.04"/>
|
||||||
|
<polyline class="cls-2" points="66.21 318.28 66.6 318.66 103.34 353.86 103.74 354.24 104.14 354.63 104.54 355.01"/>
|
||||||
|
<path class="cls-2" d="M210.79,316.19c2.01,1.62,5.55,12.81,5.16,16.41l-2.13,19.73c-8.18,1.27-16.4,2.09-24.72,1.88l-6.37-8.74c-.46-.63-1.12-1.06-1.64-1.08-.67-.03-1.56.51-2.14,1.26l-5.23,6.74-16.53,1.49c-2.64.24-5.37.61-7.42-.64l-.56-.34-3.01-2.1c-1.49-1.04-3.18-.66-3.91.8-1.16,2.56-3.95,2.42-6.56,2.51l-13.67.49-16.87,1.08-.65-.67"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #9b1f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #3a160a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #e93525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3d180d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #c12b1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-3" d="M141.35,207.84c-.15-.1-.38-.08-.57-.14-.5.53.56,1.73-.35,2.63,2.2,6.52,2.73,13.17,3.12,20.16l1.18,20.95c.3-.07.67-.04.84-.02.7-.89-.2-2.18-.08-3.4l3.51-37.22c.07-.78-.41-1.44-.5-1.88l19.5-2.14c1.96.84,1.89,4.16,2.43,5.54-.19-.36-1.21.01-1.82.21-.39.13.11.72-.4,1.5.58,10.03-3.74,18.54-4.28,28.36,3.88-6.54,14.81-30.94,15.78-31.51,1.1-.65,1.54.47,2.43.78,1.28.45,3.34-.37,3.73,1.34.25,1.09,1.66.7,2.44,1.56-.46,1.49-1.33,2.83-1.48,4.39,3.42.29,7.69-.88,11.02.3,2.37.84-.91,3.18-1.22,4.37.17,4.04,4.98,4.47,10.21,3.99.82.43.92,2.65,2.23,2.64l-4.61,3.64c4.87-.22,8.3,1.84,11.15,1.44l-.61,4.42c-.69.13-1.97-.34-2.8.24-6.06,4.22-12.42,7.63-18.95,11.04l-6.29,4.39c4.87.92,9.07-1.38,13.76-2.48,3.75-.88,7.71-1.15,11.61-1.17,1.47,0,3.02,1.79,4.47.65l-1.19,12.44c-.25,2.67-.6,5.14-1.34,7.45-.83-.98-1.63-.31-2.63.07-6.58,2.5-13.24,3.89-20.13,5l-17.39,2.78c1.75,1.3,3.52.34,5.24.34h32.08c-1.46,8.73-7.23,6.14-7.99,8.08-1.21,3.1,2.92,5.92,1.74,7.9s-3.15,3.27-4.07,5.7c2.08.02,5.13-3.57,7.39-2.41.77.39,1.39,1.77,1.11,3.19-3.36,4.96-9.42,5.44-8.61,13.35l-.74,3.75c-1.58.06-2.1,1.03-2.21,2.08l-12.24-1.78c-1.47-.21-4.87-.09-4.63,1.94l1.6,13.76c.17,1.43.76,2.77-.25,3.79l-5.27,2.54-2.14.63-1.93-1.16c-6.66,3.78-4.71,7.07-15.02,6.31l-11.95,2.56-1.39-7.83c-3.15-7.67-3.81-15.27-3.95-23.41l-.15-8.71c-.38.04-.44-.34-.87-.53l-1.62,20.27c-.21,2.63-.8,4.53-1.38,7.39l-20.37-.61c-2.55-.08-4.58-3.05-7.89-1.97-.6-2.94-2.88-3.36-5.33-3.82.34-4.53,4.71-6.78,3.51-8.96l-7.49,7.73-1.13-2.09c-1.15-2.13-1.4-5.14-4.15-6.09-.73-1.72-2.1-3.05-4.58-2.9-.17-1.75-2.45-1.79-2.86-2.71-.24-.54.43-.87-.61-.57-.2.06-3.02-3.72-3.62-7.19-3.05-.69-.25-4.22-2.63-4.24,3.16-3.19-3.84-1.11-4.81-3.41-.81-1.91-.04-3.39.74-4.96.26-.51-.56-4.29,2.44-9.1,6.33-2.1,12.4-3.33,18.8-4.23.25-.04.88.64,1.17.5.34-.16.76-.41.89-1.14l-22.09-1.45-1.42-.42c.68.2-2.27-4.82-.93-7.13,1.88-.95,4.57-.3,6.65-.77.49-.11,4.68-5.32,4.6-4.85.15-.81-.35-1.49-.9-1.62-1.08-.25-1.49,2.01-5.22,2.29-1.4.11-3.82-3.52-3.86-3.9-.09-.81.64-.76,1.09-1.56,3.25-5.81,2.03-5.22,1.61-9.67-.13-1.35-1.69-2.4-.19-3.82,2.65-2.49,5.15-5.35,6.94-8.32.25-.1.64-.51,1.11-.47l13.86,1.31c3.55-2.76,1.46-9.93,1.27-15.68l-.49-14.85c.39-.69.33-1.35.37-1.85.06-.7-.98-1.5-1.55-.94l.55-2.93.11-.58.22-1.16-.39.4.39-.34c4.32.66,9.46,1.37,13.92,1.69l14.97,1.06c3.88.27,7.66-.03,11.21,1.08.19.06.42.03.57.14ZM102.09,290.2l.93,21.66c.06,1.44-.52,3.82,1.01,4.64.98.52,2.05,1,2.45.38l1.25-1.93c.34-.52-.14-1.15.09-1.4.18-.19.64-.12.85-.03.32.12.23.16.26.39.08.53-.64.83-.5,1.16.41.96,1.63,1.3,2.11.96.43-.3,1.27-1.06,1.24-1.95l-.87-22.21c8.59-.6,16.52-3.59,22.83-8.98,7.59-6.48,9.09-17.7,2.59-25.38s-21.36-8.59-30.78-5.14c-.19-1.38-.35-2.08-.8-2.15l-2.07-.29-.32,3.56c-9.69,4.25-8.22,8.19-6.55,7.13.37-.23.75-.79,2.02-.94l-1.7,2.61c-.33.5-.28,1.36.15,1.65.29.2,1.12.33,1.58,0l4.17-3.06-.03,24.78c-1.77.34-3.13-1.12-4.55-.4-1.95.98.56,3.2,4.63,4.93ZM183.9,295.43c-2.54-1.64-4.08,8.31-13.8,10.5-5.05,1.14-10.5-1.01-13.47-5.57-4.78-7.32-5.07-16.43-2.58-24.6,3.13-10.28,10.83-20.05,19.27-15.53,5.81,3.12,2.84,9.38,6.76,8.1.76-.25,1.62-3.66.55-6.94,1.78.38,1.35,3.89,2.76,4.16,2.82.54.95-11.97-10.88-15.11-17.71-4.7-30.43,20.87-29.61,38.76.36,7.72,2.8,15.24,8.44,20.4,5.98,5.47,14.37,6.55,22.33,4.17,9.83-2.94,16.54-13.86,14.45-15.95-.52-.53-1.42-.6-1.75-.07l-.88,1.4c-.87.17-.68.78-.87.89-1.52.83,1.03-3.48-.73-4.61Z"/>
|
||||||
|
<path class="cls-5" d="M100.35,205.56l-.55,2.93c-1.08,5.75-2.71,11.68-2.27,17.86.25,3.54.85,6.96.03,10.68-8.89.06-16.45-2.38-25.75.39-.64.15-.28,1.16-.01,1.44.24.26.66-.03,1.36.22l11.41,1.34c.18.5.36.67.67.55-1.8,2.97-4.29,5.82-6.94,8.32-1.5,1.41.06,2.46.19,3.82.42,4.45,1.64,3.85-1.61,9.67-.45.81-1.18.76-1.09,1.56.04.39,2.46,4.01,3.86,3.9,3.73-.29,4.14-2.55,5.22-2.29.56.13,1.05.81.9,1.62.09-.47-4.1,4.74-4.6,4.85-2.08.48-4.77-.18-6.65.77-1.35,2.31,1.61,7.33.93,7.13l1.42.42,22.09,1.45c-.13.74-.55.98-.89,1.14-.29.14-.91-.53-1.17-.5-6.4.9-12.47,2.12-18.8,4.23-3,4.81-2.18,8.59-2.44,9.1-.78,1.57-1.55,3.04-.74,4.96.97,2.29,7.97.21,4.81,3.41,2.38.02-.42,3.55,2.63,4.24.6,3.47,3.42,7.25,3.62,7.19,1.04-.3.38.02.61.57.4.92,2.69.96,2.86,2.71,2.48-.16,3.85,1.18,4.58,2.9,2.75.95,3,3.96,4.15,6.09l1.13,2.09,7.49-7.73c1.2,2.18-3.16,4.43-3.51,8.96,2.45.45,4.73.87,5.33,3.82,3.31-1.07,5.34,1.9,7.89,1.97l20.37.61c.58-2.85,1.17-4.75,1.38-7.39l1.62-20.27c.43.19.49.57.87.53l.15,8.71c.14,8.14.8,15.74,3.95,23.41l1.39,7.83,11.95-2.56c10.32.76,8.36-2.53,15.02-6.31l1.93,1.16,2.14-.63,5.27-2.54c.4-.02.63.4.83.64.16.18-.17.6-.2,1.04l-.67,10.12c.82-.14,1.5-.45,2.27,0,2.81-6.65,3.02-13.87,2.67-21.4.15-.92-.09-2.34.39-2.85.45-.48,1.84-1.22,2.79-1.11,7.02.82,13.69.47,20.29-1.35,1.03.54,2.17.4,3.18.19.08.61.02,1.31-.5,1.83l-14.08,13.99c-3.04,3.02-6.73,5.26-9.07,8.88l-3.1,3.24c-1.03-1.01-2.03-.45-2.82-.97l-2.06,4.85-35.37-1.32c-4.34-1.79-6.54-8.04-7.73-7.92s-2.8,6.38-5.48,6.54l-10.41.64c-5.38.33-10.15.72-15.49,2.19-3.14.87-7.62.86-10.83.08-2.95-1.18-3.85-5.14-6.2-6.63-2.58.57-3.1,5.36-5.17,6.12s-15.98-.69-16.94-1.64c-1.04-1.02-1.66-2.74-1.93-4.36l-1.46-8.74c-.26-1.57-1.4-2.96-.05-5l.25-3.57c1.08-2.07.21-4.64.52-7.05l1.27-9.71c.24-1.86.5-4.12-.13-5.95l-.38-7.85c-.09-1.91-.47-3.76-.13-5.55l1.4-7.4c.42-2.24,1.09-4.38,1.82-6.51-.7-1.43-2.17-2.49-2.25-3.91l-.41-7.84-.51-7.74-.6-8.71-.3-7.65c-.86-4.02-1.08-8.37,2.47-11.45.28-.11.66-.01.92.02l30.19-31.05.84.55Z"/>
|
||||||
|
<path class="cls-5" d="M215.61,235.34c-2.85.4-6.28-1.66-11.15-1.44l4.61-3.64c-1.3,0-1.41-2.21-2.23-2.64-5.23.47-10.04.05-10.21-3.99.31-1.19,3.58-3.53,1.22-4.37-3.33-1.18-7.6,0-11.02-.3.14-1.57,1.01-2.91,1.48-4.39-.78-.86-2.19-.47-2.44-1.56-.39-1.71-2.45-.89-3.73-1.34-.89-.32-1.33-1.43-2.43-.78-.97.57-11.91,24.97-15.78,31.51.54-9.82,4.86-18.33,4.28-28.36.51-.78,0-1.37.4-1.5.61-.2,1.63-.57,1.82-.21.52,1.34,1.39,2.32,2.25,3.36l.35.42-.35-.42c2.99-4.01,4.32-9.78,6.79-11.04,1.99-1.01,6.98.63,10.05.99,2.25.26,4.67-.67,7.13.45l5.57.52c4.86.45,13.52,1.2,14.2,1.84.95.89.45,1.58.67,2.86,1.43,8.35.85,16.35-1.48,24.06Z"/>
|
||||||
|
<path class="cls-5" d="M214.27,272.31c-.4,1.25-2.02,2.41-1.74,4.07l2.61,3.32,1.18,21.98,1.27,18.14c.18,2.6-1.32,4.68-3.5,5.32-1,.22-2.14.35-3.18-.19.51-.44.48-1.12.22-2.05-4.36.28-8.77-.13-13.07-.76.11-1.05.62-2.02,2.21-2.08l.74-3.75c-.81-7.92,5.25-8.4,8.61-13.35.28-1.42-.34-2.79-1.11-3.19-2.26-1.16-5.3,2.44-7.39,2.41.92-2.43,2.85-3.65,4.07-5.7s-2.95-4.8-1.74-7.9c.76-1.94,6.53.65,7.99-8.08h-32.08c-1.72,0-3.48.96-5.24-.34l17.39-2.78c6.89-1.1,13.55-2.49,20.13-5,1-.38,1.8-1.05,2.63-.07Z"/>
|
||||||
|
<path class="cls-1" d="M148.5,208.91c.09.44.57,1.1.5,1.88l-3.51,37.22c-.12,1.22.79,2.51.08,3.4-.17-.02-.55-.05-.84.02l-1.18-20.95c-.39-6.98-.92-13.64-3.12-20.16.91-.89-.15-2.09.35-2.63.19.06.42.03.57.14,2.12,1.41,4.66,1.34,7.15,1.07Z"/>
|
||||||
|
<path class="cls-5" d="M215,239.76c-.37,2.69-1.06,4.75-4.09,5.88,0,1.06-1.32,1.19-1.19,1.7.25,1,1.38,1.43,2.04,1.7,1.6.65,5.23,1.29,5.03,3.38-1.45,1.14-3-.66-4.47-.65-3.89.02-7.85.3-11.61,1.17-4.69,1.1-8.9,3.39-13.76,2.48l6.29-4.39c6.53-3.41,12.89-6.83,18.95-11.04.83-.58,2.12-.11,2.8-.24Z"/>
|
||||||
|
<path class="cls-2" d="M102.09,290.2c-4.07-1.73-6.58-3.95-4.63-4.93,1.42-.72,2.78.75,4.55.4l.03-24.78-4.17,3.06c-.46.33-1.29.2-1.58,0-.43-.29-.48-1.15-.15-1.65l1.7-2.61c-1.27.15-1.65.71-2.02.94-1.68,1.06-3.15-2.89,6.55-7.13l.32-3.56,2.07.29c.45.06.61.77.8,2.15,9.43-3.46,24.09-2.78,30.78,5.14s5,18.9-2.59,25.38c-6.31,5.39-14.24,8.38-22.83,8.98l.87,22.21c.03.89-.81,1.65-1.24,1.95-.48.34-1.7,0-2.11-.96-.15-.34.58-.64.5-1.16-.04-.23.06-.27-.26-.39-.21-.08-.67-.16-.85.03-.23.25.25.88-.09,1.4l-1.25,1.93c-.4.62-1.47.15-2.45-.38-1.53-.82-.95-3.2-1.01-4.64l-.93-21.66ZM131.84,268.69c-.17-9.31-11.39-12.74-20.83-10.93-.29,9.08-1.11,17.3-.24,26.28,9.58-2.04,21.23-6.33,21.06-15.35Z"/>
|
||||||
|
<path class="cls-4" d="M183.9,295.43c1.76,1.14-.79,5.45.73,4.61.19-.11,0-.72.87-.89l.88-1.4c.33-.52,1.23-.45,1.75.07,2.08,2.09-4.63,13.02-14.45,15.95-7.96,2.38-16.35,1.3-22.33-4.17-5.64-5.17-8.09-12.68-8.44-20.4-.82-17.89,11.9-43.46,29.61-38.76,11.84,3.14,13.7,15.66,10.88,15.11-1.41-.27-.98-3.78-2.76-4.16,1.07,3.28.2,6.69-.55,6.94-3.93,1.28-.95-4.98-6.76-8.1-8.45-4.53-16.14,5.24-19.27,15.53-2.49,8.17-2.2,17.28,2.58,24.6,2.98,4.56,8.43,6.7,13.47,5.57,9.71-2.19,11.26-12.13,13.8-10.5ZM177.02,256.36l3.08,4.12c1.58-.8-.62-4.44-3.08-4.12Z"/>
|
||||||
|
<path class="cls-1" d="M198.07,322.14c4.3.63,8.71,1.03,13.07.76.25.92.29,1.6-.22,2.05-6.61,1.82-13.27,2.17-20.29,1.35-.95-.11-2.34.63-2.79,1.11-.48.51-.23,1.94-.39,2.85.34,7.53.14,14.75-2.67,21.4-.77-.46-1.44-.14-2.27,0l.67-10.12c.03-.44.36-.86.2-1.04-.2-.24-.43-.66-.83-.64,1.02-1.02.42-2.37.25-3.79l-1.6-13.76c-.24-2.03,3.16-2.15,4.63-1.94l12.24,1.78Z"/>
|
||||||
|
<path class="cls-1" d="M85.24,240.96c-.31.13-.49-.05-.67-.55l-11.41-1.34c-.71-.24-1.12.04-1.36-.22-.26-.28-.63-1.29.01-1.44,9.3-2.77,16.87-.34,25.75-.39.82-3.72.22-7.14-.03-10.68-.44-6.17,1.19-12.1,2.27-17.86.58-.57,1.61.24,1.55.94-.04.5.02,1.16-.37,1.85l.49,14.85c.19,5.76,2.28,12.92-1.27,15.68l-13.86-1.31c-.47-.04-.87.37-1.11.47Z"/>
|
||||||
|
<path class="cls-3" d="M131.84,268.69c.17,9.02-11.48,13.31-21.06,15.35-.87-8.98-.05-17.2.24-26.28,9.44-1.81,20.65,1.63,20.83,10.93Z"/>
|
||||||
|
<path class="cls-1" d="M177.02,256.36c2.46-.32,4.66,3.32,3.08,4.12l-3.08-4.12Z"/>
|
||||||
|
<path class="cls-6" d="M211.77,249.04c1.6.65,5.23,1.29,5.03,3.38l-1.19,12.44c-.25,2.67-.6,5.14-1.34,7.45-.4,1.25-2.02,2.41-1.74,4.07l2.61,3.32,1.18,21.98,1.27,18.14c.18,2.6-1.32,4.68-3.5,5.32-1,.22-2.14.35-3.18-.19-6.61,1.82-13.27,2.17-20.29,1.35-.95-.11-2.34.63-2.79,1.11-.48.51-.23,1.94-.39,2.85.34,7.53.14,14.75-2.67,21.4-.09.1-.19.28-.26.44l-2.06,4.85-35.37-1.32c-4.34-1.79-6.54-8.04-7.73-7.92s-2.8,6.38-5.48,6.54l-10.41.64c-5.38.33-10.15.72-15.49,2.19-3.14.87-7.62.86-10.83.08-2.95-1.18-3.85-5.14-6.2-6.63"/>
|
||||||
|
<path class="cls-6" d="M100.69,203.88c4.32.66,9.46,1.37,13.92,1.69l14.97,1.06c3.88.27,7.66-.03,11.21,1.08.19.06.42.03.57.14,2.12,1.41,4.66,1.34,7.15,1.07l19.5-2.14c1.96.84,1.89,4.16,2.43,5.54s1.39,2.32,2.25,3.36l.35.42"/>
|
||||||
|
<path class="cls-6" d="M100.68,203.82l-.39.4-.78.79-30.19,31.05c.27,1.12,1.58,1.24,2.49,1.36,9.3-2.77,16.87-.34,25.75-.39.82-3.72.22-7.14-.03-10.68-.44-6.17,1.19-12.1,2.27-17.86l.55-2.93.11-.58.22-1.16Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #0db3c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #007988;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #0c96a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3a170d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #3c1b0d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-1" d="M214.65,239.1l-2.58-.1c-3.79,2.1-7.73,4.08-11.61,6.32l-6.56,3.79c-2.2,1.27-5.02,2.22-6.12,5.03l12.97-3.01,11.9-1.19c.44-.63.06-1.58.21-2.2,1.11.52,3.9,1.16,3.71,3.1l-1.82,18.71c-1.5,1.84-3.42,2.56-4.11,4.45-.39.04-.84-.28-1.1-.57-.2-.23.18-1.3-.21-1.23l-13.32,2.67-24.66,3.99c2.94.91,5.47.34,8.11.31l30.61-.32c.58,0,1.24.32,1.55.45s.06,1.13-.11,1.49l-5.94,12.1-2.34,5.89c1.31.57,2.15-1.03,3.19-1.23,1.71-.34,2.18,1.87,2.29,2.91.19,1.76-.21,2.53-1.63,3.75l-3.61,3.1c-2.25,1.93-2.51,5.48-3.11,8.25l-2.36,4.83-14.21-1.65c-1.16-.13-2.3.56-2.59,1.21-1.13,2.5,3.49,19.87,1.51,29.66l2.38-.32.4,1.88c-.02,1.11-.76,2.05-1.49,2.86-.9,1-2.37.8-3.88.8l-24.17.07c-3.64.01-6.98.15-10.19-1.01l-.55-.2c.38-.35-.12-1.32,1-2.02l-3.02-10.07c-.55-1.82-.65-4.03-.68-6.05l-.38-23.49c0-.35.21-1.3-.12-1.78-.19-.26,1.36-1.02-.61-1.17l-4.19,32.02c-.53,4.08-2.01,7.72-1.64,11.98,1.56-.87,2.59-.64,3.6-.38-1.06.63-1.9,2.74-3.63,3-8.84,1.34-17.96,1.85-26.77.44l-13.88-3.27c-1.56-1.74-2.19-3.54-3.98-4.71l-6.41,7.6c-5.24-.33-10.27-1.31-15.49-2.21-2.15-4.26-.12-10.16-1.62-14.2-1.64-4.39-1.98-8.67-1.04-13.21,1.9-9.16,1.37-17.8.49-27.02-.43-4.52,3.2-9.56,5.02-12.83l.8-.58.55-.55-.55.55-.8.58c.11.87,1.36,1.26,2.07.83.33-.2.87.26,1.48-.62l30.23-5.39c1.79-.32,6.53.08,5.59-1.14-.12-.15-.41-.79-.78-.81l-16.48-.56-11.54.04c-3.89-.56-7.57.1-11.44-1.87.21,1.09-.13,1.57-.76,1.25-.22-.11-.39.11-.92.03l-2.78-19.95c-.32-2.3-1.51-5.82-.08-7.61l6.94-4.85c.64-.45.02-1.82-.8-1.97-2.71-1.37-4.11-3.91-2.9-7.27-2.45-4.5-2.75-9.25-2.39-14.17l.97-13.21c.38-5.16,5.65-2.67,12.92-.43,3.25.54,6.3-.23,9.33-1.63,8.42.55,16.41-.06,24.8-.67l16.36-1.2,8.59,1.05c.2.02.38.14.56.21-.19-.07-.37-.18-.56-.21-.54,4.1,1.56,7.36,1.72,9.19l1.11,13.13.8,17.66-.2,8.37c.25,0,.57-.14.92.02l3.92-47.24-.57.22.57-.22,19.53-1.91c1.57.6,1.43,3.49,2.12,4.44.09.12.09.4.12.6-.03-.2-.03-.48-.12-.6l-2.15.91c.17,8.51-1.44,13.81-3.15,21.63l-2.13,9.75c2.92-2.92,3.58-6.64,5.27-10.02l10.81-21.63c.85-1.69.49-4.05,3.09-4.69-.5-.7-.42-1.53-.6-2.46,1.54.33,3.13-.5,4.85.26l5.72.4,10.88,1.12,6.13.57c2.16.2,6.59-.53,8.1,2.25.4,6.45,1.14,13.48-.1,19.79l-2.24,11.36ZM168.83,303.16c-6.94.18-12.13-5.11-13.68-11.47-1.72-5.03-2.28-10.32-.88-15.55,1.12-10.54,9.73-25.45,20.19-20.69,5.19,2.36,4.68,11.87,7.33,8.34-.13.18.87-3.46,1.06-3.05-.47-1.02-.68-2.77-.56-2.97.38-.62.92-.03.9.33-.01.16.1.13.26.77.43.68.42,1.72.95,1.95,3.46,1.48,1.8-9.41-6.67-13.59-5.47-2.7-10.86-3.08-15.84-.29-7.33,4.11-11.48,11.04-14.58,18.6-3.51,8.58-5.17,17.76-2.65,26.66,1.55,5.5,3.59,11.18,8.36,15.03,6.06,4.89,14.44,6.2,22.41,3.63,13.87-4.48,18.3-21.76,11.29-15.14-.28.27.49.89.09,1.03-.25.08-.69-.15-1.14.58-.32.51.58,1.26-.3,1.42l-1.48.26,1.93-3.85c.54-1.09.15-2.4-.76-2.62-3.3-.79-4.86,10.32-16.21,10.62ZM99.04,299.48l-2.33-4.68-3.47.03c-.04,6.06,3.08,10.63,7.43,14.01,4.93,3.83,10.3,5.14,16.54,4.87,10.18-.44,19.57-6.79,21.53-16.96,1.57-8.14-2.54-15.62-9.31-19.96-7.16-4.59-16.77-5.53-20.09-10.13-1.83-2.53-1.29-5.65.09-8.51,1.08-2.23,3.67-3.76,6.68-4.4,8.68-1.84,13.61,5.15,15,4.52.46-.2,1.46-.79,1.17-1.39l-1.53-3.17c2.36.38,3.63,3.38,4.81,1.76,1.42-1.94-9.35-13.75-24.73-9.15-8.64,2.58-13.65,10.74-11.83,19.53.96,4.64,3.52,8.84,8.3,10.67l12.22,4.68c6.12,2.34,10.3,8.86,8.33,15.46-1.45,4.86-6.35,7.18-11.25,7.51-5.75.39-12.14-2.11-13.82-7.94-.66-2.3-.6-5.38-4.17-5.51-.75,4.05,1.16,6.89.42,8.75ZM103.94,315l-7.75,6.79c.97.96,1.64.88,2.27.52-.28.16,5.09-5.27,5.48-7.31Z"/>
|
||||||
|
<path class="cls-3" d="M210.63,274c-.34,2.98,4.2,2.59,4.27,3.76l2.51,42.37c.1,1.67-2.35,3.33-3.58,3.16l.11-2.9c-5.22.96-10.62.62-15.97,0l2.36-4.83c.6-2.77.86-6.32,3.11-8.25l3.61-3.1c1.42-1.22,1.82-1.99,1.63-3.75-.11-1.05-.58-3.25-2.29-2.91-1.04.2-1.88,1.81-3.19,1.23l2.34-5.89,5.94-12.1c.18-.36.42-1.36.11-1.49s-.97-.45-1.55-.45l-30.61.32c-2.64.03-5.17.59-8.11-.31l24.66-3.99,13.32-2.67c.39-.08,0,1,.21,1.23.25.29.71.61,1.1.57Z"/>
|
||||||
|
<path class="cls-1" d="M213.84,323.29c.69.99.45,1.15-.19,1.78l-17.39,17.12c-3.16,3.11-6.44,5.57-9.4,9.05-.38.45-.97.07-1.39-.06l-.4-1.88c1.94-6.68,3.23-13.66,2.17-20.91-.22-1.52-.28-2.52.82-3.51.9-.81,1.78-.11,3.06-.13l14.27-.23c3.03-.05,5.64-1.6,8.45-1.22Z"/>
|
||||||
|
<path class="cls-3" d="M69.29,278.12c.52.08.69-.14.92-.03.63.32.97-.16.76-1.25,3.87,1.96,7.55,1.31,11.44,1.87l11.54-.04,16.48.56c.38.01.66.65.78.81.93,1.22-3.8.82-5.59,1.14l-30.23,5.39c-.61.87-1.15.42-1.48.62-.71.43-1.96.04-2.07-.83l.8-.58.55-.55-.55.55.55-.55c.13-.13.21-.35.38-.39.47-.12.74-1.1.12-1.45-.29-.17-.73,0-.96-1.07-.92-1.33-3.2-2.39-3.45-4.18Z"/>
|
||||||
|
<path class="cls-3" d="M145.75,353.91l-.55-.2c-1.16-.42-2.27-1.18-3.54-1.9-1.2-.68-1.89,1.02-2.51.94-1.01-.26-2.05-.49-3.6.38-.38-4.26,1.1-7.9,1.64-11.98l4.19-32.02c1.97.15.43.91.61,1.17.33.47.11,1.42.12,1.78l.38,23.49c.03,2.02.13,4.23.68,6.05l3.02,10.07c-1.11.7-.61,1.67-1,2.02l.55.2Z"/>
|
||||||
|
<path class="cls-2" d="M181.3,203.36c.18.94.09,1.76.6,2.46-2.6.64-2.24,3-3.09,4.69l-10.81,21.63c-1.69,3.38-2.35,7.1-5.27,10.02l2.13-9.75c1.71-7.83,3.31-13.12,3.15-21.63l2.15-.91c.09.12.09.4.12.6.19,1.19.46,2.37,1.32,3.3.37-.75,1.52-.99,1.94-1.77l3.72-6.88c.54-1,.67-1.36,1.64-1.89,1.02-.56,1.31-.09,2.4.14Z"/>
|
||||||
|
<path class="cls-3" d="M147.94,207.55l.57-.22-3.92,47.24c-.34-.16-.67-.01-.92-.02l.2-8.37-.8-17.66-1.11-13.13c-.15-1.82-2.26-5.09-1.72-9.19.2.02.38.14.56.21,2.24.82,4.78.99,7.14,1.13Z"/>
|
||||||
|
<path class="cls-2" d="M214.65,239.1c-.45,2.26-2.11,3.94-3.91,5.65-.84.16-1.05.39-1.13.79-.15.82.08,1.71,1.03,1.66.76-.04,1.5.2,2.23.54-.16.62.22,1.57-.21,2.2l-11.9,1.19-12.97,3.01c1.1-2.81,3.92-3.76,6.12-5.03l6.56-3.79c3.88-2.24,7.82-4.22,11.61-6.32l2.58.1Z"/>
|
||||||
|
<path class="cls-4" d="M99.04,299.48c.73-1.86-1.18-4.7-.42-8.75,3.57.13,3.51,3.2,4.17,5.51,1.68,5.83,8.07,8.33,13.82,7.94,4.9-.33,9.79-2.65,11.25-7.51,1.97-6.6-2.21-13.11-8.33-15.46l-12.22-4.68c-4.78-1.83-7.34-6.03-8.3-10.67-1.82-8.79,3.19-16.95,11.83-19.53,15.38-4.6,26.15,7.2,24.73,9.15-1.18,1.62-2.45-1.38-4.81-1.76l1.53,3.17c.29.6-.71,1.18-1.17,1.39-1.39.62-6.32-6.36-15-4.52-3.02.64-5.61,2.16-6.68,4.4-1.38,2.86-1.93,5.98-.09,8.51,3.33,4.59,12.93,5.53,20.09,10.13,6.77,4.34,10.89,11.82,9.31,19.96-1.96,10.18-11.36,16.52-21.53,16.96-6.25.27-11.61-1.04-16.54-4.87-4.35-3.38-7.47-7.95-7.43-14.01l3.47-.03,2.33,4.68Z"/>
|
||||||
|
<path class="cls-6" d="M168.83,303.16c11.35-.3,12.91-11.42,16.21-10.62.9.22,1.3,1.53.76,2.62l-1.93,3.85,1.48-.26c.88-.16-.02-.91.3-1.42.46-.73.9-.5,1.14-.58.4-.14-.37-.76-.09-1.03,7.01-6.62,2.58,10.66-11.29,15.14-7.97,2.58-16.35,1.27-22.41-3.63-4.76-3.85-6.8-9.53-8.36-15.03-2.51-8.9-.86-18.09,2.65-26.66,3.1-7.56,7.25-14.49,14.58-18.6,4.98-2.79,10.38-2.41,15.84.29,8.47,4.18,10.13,15.07,6.67,13.59-.52-.22-.52-1.27-.95-1.95-.16-.64-.27-.62-.26-.77.02-.36-.52-.95-.9-.33-.12.2.09,1.95.56,2.97-.19-.41-1.19,3.23-1.06,3.05-2.65,3.53-2.14-5.98-7.33-8.34-10.46-4.76-19.07,10.15-20.19,20.69-1.4,5.23-.84,10.52.88,15.55,1.55,6.36,6.74,11.66,13.68,11.47Z"/>
|
||||||
|
<path class="cls-2" d="M197.98,320.39c5.36.62,10.75.96,15.97,0l-.11,2.9c-2.81-.38-5.42,1.17-8.45,1.22l-14.27.23c-1.27.02-2.16-.67-3.06.13-1.1.99-1.04,1.99-.82,3.51,1.06,7.25-.23,14.24-2.17,20.91l-2.38.32c1.98-9.79-2.64-27.16-1.51-29.66.29-.65,1.43-1.35,2.59-1.21l14.21,1.65Z"/>
|
||||||
|
<path class="cls-3" d="M103.94,315c-.39,2.04-5.76,7.47-5.48,7.31-.63.36-1.3.44-2.27-.52l7.75-6.79Z"/>
|
||||||
|
<path class="cls-5" d="M210.64,247.2c.76-.04,1.5.2,2.23.54,1.11.52,3.9,1.16,3.71,3.1l-1.82,18.71c-1.5,1.84-3.42,2.56-4.11,4.45-.34,2.98,4.2,2.59,4.27,3.76l2.51,42.37c.1,1.67-2.35,3.33-3.58,3.16-2.81-.38-5.42,1.17-8.45,1.22l-14.27.23c-1.27.02-2.16-.67-3.06.13-1.1.99-1.04,1.99-.82,3.51,1.06,7.25-.23,14.24-2.17,20.91l.4,1.88c-.02,1.11-.76,2.05-1.49,2.86-.9,1-2.37.8-3.88.8l-24.17.07c-3.64.01-6.98.15-10.19-1.01l-.55-.2c-1.16-.42-2.27-1.18-3.54-1.9-1.2-.68-1.89,1.02-2.51.94-1.06.63-1.9,2.74-3.63,3-8.84,1.34-17.96,1.85-26.77.44l-13.88-3.27c-1.56-1.74-2.19-3.54-3.98-4.71l-6.41,7.6c-5.24-.33-10.27-1.31-15.49-2.21-2.15-4.26-.12-10.16-1.62-14.2-1.64-4.39-1.98-8.67-1.04-13.21,1.9-9.16,1.37-17.8.49-27.02-.43-4.52,3.2-9.56,5.02-12.83l.8-.58.55-.55c.13-.13.21-.35.38-.39"/>
|
||||||
|
<path class="cls-5" d="M172.5,214.73l-.9-.97c-.85-.92-1.12-2.1-1.32-3.3-.03-.2-.03-.48-.12-.6-.7-.95-.55-3.83-2.12-4.44l-19.53,1.91-.57.22c-2.36-.14-4.9-.31-7.14-1.13-.19-.07-.37-.18-.56-.21l-8.59-1.05-16.36,1.2c-8.38.62-16.38,1.23-24.8.67-3.03,1.4-6.08,2.17-9.33,1.63-7.26-2.24-12.54-4.74-12.92.43l-.97,13.21c-.36,4.92-.07,9.67,2.39,14.17-1.22,3.36.19,5.9,2.9,7.27"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.7 KiB |
62
src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #ead08e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #e1bc70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #c8a363;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #d2ab67;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #e7c278;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-7 {
|
||||||
|
fill: #dfbc6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-8 {
|
||||||
|
fill: #cfa864;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-9 {
|
||||||
|
fill: #f4dfa9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-10 {
|
||||||
|
fill: #d0a965;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-9" d="M176.79,209.43l-2.01.07-2.45,11.42c.4,3.9-.63,6.69-1.9,10.36l-3.61,10.39c.59.78.74,1.77,1.37.7l7.89-16.39,7.62-10.14c2.27.08,3.91.46,5.82.02-.48,1.32-.34,1.86-.19,3.24,2.27-.16,4.39.54,6.42,1.88,1.42-1.07,2.79-1.65,4.6-1.66h3.49c.68.88-.13,2.84-1.05,3.79-.72.74.87,3.35,1.92,3.32l6.17-.17c1.75,1.96,1.05,4.36.98,6.82-.21,7.02-6.11,2.65-10.61,6.97-.68.65-.91,2.18-.23,2.68.84.62,4.26-1.19,5.18,1.09.05.12-2.19,4.54-4.01,7.24l3.5,4.78c.67.91,1.01,1.73,1.7,1.94,2.5.74,3.42-1.07,5.16.15l-.16-6.2c5.06-.66.3,10.94,7.05,9.19-.39,1.97-.65,4.21-1.67,6.09l-.88,1.61-23.67,3.92-26.09,5.6,28.81-1.28,21.9,1.79,2.66,21.59c.23,1.91.61,4.63,0,6.52-.9,2.75-4.75,3.45-6.85,4.52-.11-.6-.55-.77-.29-.96.27-.2.75-.49.46-.76-1.69-1.58-3.7-.88-5.42-1.5l-25.61-9.14c-2.01-.72-4.18-2.11-5.81.36,9.47,1.99,17.53,6.65,23.36,13.99,3.22,1.6,5.73,3.69,8.19,6.23,2.46,1.69,4.62,3.8,5.34,6.49,1.07-.18.92.5,1.05.83.15.38-.61.67-.62,1-.12,4.43-3.89,4.36-7.11,9.5-2.55,4.08-7.38,4.35-9.21,6.15l-.94.92c-.16.16.8.55.24.65-.67.12-1.28.23-1.51.37-.2.12.32.78-.29.87-1.03.15-2.52-.07-3.24.26-.61.28-1.54,1.08-1.53,2.3.01,1.24,3.21,1.69,4.07.81-.22,1.01.07,1.8.09,2.48l-6.88-.51c-3.33-.25-6.05-1.19-8.14-3.56l1.22-1.88c.25-.38-.05-1.81-.78-2.58l-7.92-8.31-1.79-6.55c1.07-.94.71-1.26-.44-1.1.06,2.16-1.07,5.3.01,7.63,2.52,5.41,2.99,11.11,2.18,16.92l-13.46,1.33c-2.91.29-5.41-.37-7.43-2.26l-.56-.52c.46-.08,1.32-.85.89-.92-.9-.15-.17-.72-.3-1.29-2.17-9.59-2.64-19.05-3.8-28.66l-2.19-18.19c-.03-.27-.5-.91-.37-1.27.06-.16-.25-.18-.89-.27.05,8.57-.38,17.06-1.41,25.88l-2.92,25.11c-2.05-1.12-1.73-2.64-2.66-3.77-2.42-1.3-5.35-.57-7.72.19-.82-1.15-2.95-.7-3.79-1.24l-7.41-4.74c-1.04-.66-1.4-2.15-1.42-3.1-.05-2.5,5.64-4.26,5.39-8.93l-4.61,2.69c-2.5,1.46-5.16,2.36-7.42,4.49,1.1,1.73,2.39,6.23.43,7.73-2.2,1.69-4.11-1.83-4.14-3.67-.04-2.86-4.05-4.49-6.8-4.27-2.19-1.72-4.47-2.69-7.41-2.81,2.92-5.58,6.84-9.71,11.07-13.92l9.26-9.22c-4.84,1.85-8.54,5.02-12.9,7.76l-13.73,8.66c-2.37-3.34-2.85-6.15-7.7-5.29-.62-.03-1.45-1.19-2.31-.96-.3-2.98.95-6.63,3.72-8.23,2.3-1.71,4.51-3.28,7.48-2.66l9.81-4.84-3.17-6.89c-.54-1.17-2.95-1.86-4.17-1.27-1.03.5-3.22,1.58-2.74,3.27.24.84,2.09,1.72,1.17,2.66-1.77,1.82-4.46,1.82-7,1.48-3.12-5.27-4.99-4.03-6.2-5.44-1.11-1.28-.17-2.47-1.16-4.71-.95-.19-2.17.03-3.09-.52l.47-8.25c5.41-1.54,11-1.66,16.65-2.11l13.76-1.11,23.43-2.65c.72-.08,1.55-.2,1.17-1.12l-34.67-.55-10.78-.52-6.8-1.66c.07.32.09.89.3,1.07.4.35-.32.87-.61,1.11-.78-.59-2.15-1.21-2.35-2.59l-1.83-12.66c-.25-1.72.79-3.24,1.79-4.12.41-.03,1.02.46.89.93-.09.34-.92.63-.78,1.42l24.02-6.49,13.85-1.37,22.02-.39-2.6-11.6c1.4-.26,3.69-1.85,4.49-2.9.89-1.16-.7-2.67-.56-3.76l1.42-10.88c2.44.37,3.7.45,5.42.41l.57,3.78,1.87,20.67c.26,2.86-.56,5.85,1.49,8.37.13-9.58.68-18.19,1.32-27.3l1.78-21.14c-.18-.34.15-.71.44-.92.45-.32.07-.8-.36-1.29.1.71-.38.67-.55.8.16-.14.65-.09.55-.8l26.18-2.61c2.68-.27,3.68,4.3,4.17,6.14Z"/>
|
||||||
|
<path class="cls-2" d="M70.64,293.55c.93.55,2.14.33,3.09.52.99,2.25.05,3.43,1.16,4.71,1.21,1.41,3.09.17,6.2,5.44,2.54.34,5.22.34,7-1.48.92-.95-.93-1.82-1.17-2.66-.48-1.69,1.71-2.77,2.74-3.27,1.22-.6,3.63.1,4.17,1.27l3.17,6.89-9.81,4.84c-2.97-.61-5.17.95-7.48,2.66-2.77,1.6-4.02,5.25-3.72,8.23.86-.23,1.7.94,2.31.96,4.85-.86,5.33,1.94,7.7,5.29l13.73-8.66c4.36-2.75,8.06-5.91,12.9-7.76l-9.26,9.22c-4.23,4.21-8.15,8.34-11.07,13.92,2.94.13,5.21,1.09,7.41,2.81,2.75-.22,6.76,1.41,6.8,4.27.03,1.84,1.94,5.36,4.14,3.67,1.95-1.5.67-6-.43-7.73,2.26-2.13,4.92-3.03,7.42-4.49l4.61-2.69c.25,4.67-5.44,6.43-5.39,8.93.02.95.39,2.43,1.42,3.1l7.41,4.74c.84.54,2.97.08,3.79,1.24,2.37-.76,5.3-1.48,7.72-.19.94,1.13.61,2.65,2.66,3.77l2.92-25.11c1.03-8.82,1.46-17.32,1.41-25.88.64.09.95.11.89.27-.13.37.34,1,.37,1.27l2.19,18.19c1.16,9.61,1.63,19.07,3.8,28.66.13.57-.6,1.14.3,1.29.43.07-.43.84-.89.92l.56.52-.56-.52c-1-.94-2.21-2.22-3.88-2.29-1.16,1.06-1.06,3.19-2.51,4-2.11,1.17-23.76,4.92-29.64,4.22l-26.91-3.21c-4.28-.51-8.3-1.54-12.64-.83-1.01.17-1.84.08-2.48-.35-1.08-.72-2.51-11.79-5.03-19.97-.63-2.06-.71-4.42-.42-6.58l2.23-17.05,1.07-15.1Z"/>
|
||||||
|
<path class="cls-1" d="M138.74,206.71c-.44,1.03-1.52,1.13-2.79.95l2.89,11.57.5,4.51c-1.72.04-2.98-.04-5.42-.41l-1.42,10.88c-.14,1.09,1.45,2.61.56,3.76-.8,1.04-3.08,2.63-4.49,2.9-2.41-10.77-4.78-21.47-4.96-32.84-.01-.81-.89-1-1.61-.47-.4.29-.56-.28-.69.82-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19l-.45-.56-.41.43-.35.37.35-.37.41-.43,47.79-50.52c5.56-.03,11.1.34,15.74,2.42Z"/>
|
||||||
|
<path class="cls-7" d="M219.43,260.9c-6.75,1.75-1.99-9.85-7.05-9.19l.16,6.2c-1.73-1.22-2.65.59-5.16-.15-.69-.2-1.03-1.02-1.7-1.94l-3.5-4.78c1.82-2.7,4.06-7.11,4.01-7.24-.92-2.28-4.34-.47-5.18-1.09-.68-.5-.45-2.03.23-2.68,4.5-4.32,10.41.05,10.61-6.97.07-2.46.77-4.86-.98-6.82l-6.17.17c-1.05.03-2.64-2.58-1.92-3.32.92-.95,1.73-2.91,1.05-3.8h-3.49c-1.81.02-3.18.6-4.6,1.67-2.03-1.33-4.15-2.04-6.42-1.88-.15-1.38-.29-1.92.19-3.24-1.91.44-3.55.07-5.82-.02l-7.62,10.14-7.89,16.39c-.62,1.07-.78.08-1.37-.7l3.61-10.39c1.27-3.66,2.3-6.46,1.9-10.36l2.45-11.42,2.01-.07c.39,1.47,1.68,1.42,2.71,3.07l2.72-3.52c1.28-1.66,2.13-2.36,4.21-2.02,4.76.8,11.51-5.31,15.84-3.2l5.09.33,6.04.66c1.5.16,3.24-.48,4.43,1.23.92,1.32.41,2.84.7,4.58l2.01,12.15c.75,4.53-.52,9.03-.78,12.69l-.41,5.63-.11,12.64c-.02,2.58.65,4.91.18,7.23Z"/>
|
||||||
|
<path class="cls-5" d="M194.84,352.09c-.02-.68-.3-1.47-.09-2.48-.86.88-4.06.43-4.07-.81,0-1.21.92-2.02,1.53-2.3.72-.33,2.21-.11,3.24-.26.61-.09.09-.75.29-.87.23-.14.84-.25,1.51-.37.55-.1-.4-.49-.24-.65l.94-.92c1.83-1.8,6.66-2.07,9.21-6.15,3.22-5.14,6.99-5.06,7.11-9.5,0-.33.77-.62.62-1-.13-.33.02-1.01-1.05-.83-.72-2.69-2.89-4.8-5.34-6.49-2.46-2.53-4.96-4.63-8.19-6.23-5.84-7.33-13.89-12-23.36-13.99,1.63-2.47,3.8-1.08,5.81-.36l25.61,9.14c1.72.61,3.72-.09,5.42,1.5.29.27-.19.56-.46.76-.25.19.18.36.29.96l-2.06,1.95c1.07.86,1.21,1.79,2.05,2.05,1.4.43,2.37,1.45,3.11,2.99.49,1.04-.89,2.78-.4,4.19l3.22,9.28-1.02,21.79-10.21-.23-13.46-1.16Z"/>
|
||||||
|
<path class="cls-3" d="M128.56,240.87l2.6,11.6-22.02.39-13.85,1.37-24.02,6.49c-.14-.79.69-1.08.78-1.42.12-.46-.48-.95-.89-.93l3.19-2.78.1.02.35-.37.41-.43.45.56c1.45.49,2.55-.4,4.04-1.19,10.61-5.66,22.12-8.74,34.02-9.94l6.62-.66c1.07-.11,1.92.74,2.58-.15.52-.71.86-1.29.66-2.45l-1.81-10.39c-1.32-7.6-1.36-14.87-.45-22.21.14-1.1.29-.53.69-.82.72-.53,1.6-.34,1.61.47.18,11.37,2.55,22.07,4.96,32.84Z"/>
|
||||||
|
<path class="cls-8" d="M216.88,268.6l-1.26,4.05c1.5,1.91,1.96,3.93,2.21,5.96l-21.9-1.79-28.81,1.28,26.09-5.6,23.67-3.92Z"/>
|
||||||
|
<path class="cls-8" d="M73.54,277.75c.29-.24,1.01-.76.61-1.11-.21-.18-.23-.75-.3-1.07l6.8,1.66,10.78.52,34.67.55c.38.92-.45,1.04-1.17,1.12l-23.43,2.65-13.76,1.11c-5.64.46-11.23.57-16.65,2.11.22-3.96,6.26-5.09,2.43-7.55Z"/>
|
||||||
|
<path class="cls-10" d="M145.9,206.7c.16-.14.65-.09.55-.8.43.49.82.97.36,1.29-.29.21-.61.58-.44.92l-1.78,21.14c-.64,9.11-1.2,17.72-1.32,27.3-2.05-2.51-1.23-5.51-1.49-8.37l-1.87-20.67-.57-3.78-.5-4.51-2.89-11.57c1.27.18,2.35.08,2.79-.95,1.34.6,2.38,2.57,3.92,2.39s2.25-1.55,3.24-2.4Z"/>
|
||||||
|
<path class="cls-4" d="M179.83,348.02c-3.45-2.21-1.4,3.53-7.52,4.14.81-5.81.34-11.5-2.18-16.92-1.09-2.33.04-5.47-.01-7.63,1.15-.16,1.51.16.44,1.1l1.79,6.55,7.92,8.31c.73.77,1.03,2.2.78,2.58l-1.22,1.88Z"/>
|
||||||
|
<path class="cls-6" d="M176.79,209.43c-.49-1.84-1.49-6.41-4.17-6.14l-26.18,2.61c.1.71-.38.67-.55.8-.99.85-1.73,2.22-3.24,2.4s-2.58-1.79-3.92-2.39c-4.64-2.08-10.18-2.45-15.74-2.42l-47.79,50.52-.41.43-.45.35-3.19,2.78c-1,.87-2.04,2.4-1.79,4.12l1.83,12.66c.2,1.38,1.57,2.01,2.35,2.59,3.83,2.46-2.21,3.6-2.43,7.55l-.47,8.25-1.07,15.1-2.23,17.05c-.28,2.16-.21,4.53.42,6.58,2.52,8.18,3.95,19.25,5.03,19.97.64.42,1.47.51,2.48.35,4.34-.71,8.36.32,12.64.83l26.91,3.21c5.88.7,27.53-3.05,29.64-4.22,1.45-.81,1.35-2.94,2.51-4,1.67.07,2.88,1.35,3.88,2.29l.56.52c2.02,1.89,4.52,2.55,7.43,2.26l13.46-1.33c6.12-.6,4.07-6.35,7.52-4.14,2.09,2.37,4.82,3.31,8.14,3.56l6.88.51,13.46,1.16,10.21.23,1.02-21.79-3.22-9.28c-.49-1.42.9-3.16.4-4.19-.73-1.54-1.71-2.56-3.11-2.99"/>
|
||||||
|
<path class="cls-6" d="M121.3,208.37c-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19-.41-.14-.87-.19-1.2.24"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -1,10 +1,28 @@
|
|||||||
var RoleSelect = (function () {
|
var RoleSelect = (function () {
|
||||||
|
// Set to true by handleTurnChanged so that a WS turn_changed that races
|
||||||
|
// ahead of the fetch response doesn't get overridden by Tray.open().
|
||||||
|
var _turnChangedBeforeFetch = false;
|
||||||
|
|
||||||
|
// Set to true while placeCard animation is running. handleTurnChanged
|
||||||
|
// defers its work until the animation completes.
|
||||||
|
var _animationPending = false;
|
||||||
|
var _pendingTurnChange = null;
|
||||||
|
|
||||||
|
// Delay before the tray animation begins (ms). Gives the gamer a moment
|
||||||
|
// to see their pick confirmed before the tray slides in. Set to 0 by
|
||||||
|
// _testReset() so Jasmine tests don't need jasmine.clock().
|
||||||
|
var _placeCardDelay = 3000;
|
||||||
|
|
||||||
|
// Delay after the tray closes before advancing to the next turn (ms).
|
||||||
|
// Gives the gamer a moment to see their confirmed seat before the turn moves.
|
||||||
|
var _postTrayDelay = 3000;
|
||||||
|
|
||||||
var ROLES = [
|
var ROLES = [
|
||||||
{ code: "PC", name: "Player", element: "Fire" },
|
|
||||||
{ code: "BC", name: "Builder", element: "Stone" },
|
|
||||||
{ code: "SC", name: "Shepherd", element: "Air" },
|
{ code: "SC", name: "Shepherd", element: "Air" },
|
||||||
{ code: "AC", name: "Alchemist", element: "Water" },
|
{ code: "PC", name: "Player", element: "Fire" },
|
||||||
{ code: "NC", name: "Narrator", element: "Time" },
|
{ code: "NC", name: "Narrator", element: "Time" },
|
||||||
|
{ code: "AC", name: "Alchemist", element: "Water" },
|
||||||
|
{ code: "BC", name: "Builder", element: "Stone" },
|
||||||
{ code: "EC", name: "Economist", element: "Space" },
|
{ code: "EC", name: "Economist", element: "Space" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -23,26 +41,45 @@ var RoleSelect = (function () {
|
|||||||
if (backdrop) backdrop.remove();
|
if (backdrop) backdrop.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRole(roleCode, cardEl) {
|
function selectRole(roleCode) {
|
||||||
var invCard = cardEl.cloneNode(true);
|
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
|
||||||
invCard.classList.add("flipped");
|
|
||||||
// strip old event listeners from the clone by replacing with a clean copy
|
|
||||||
var clean = invCard.cloneNode(true);
|
|
||||||
|
|
||||||
closeFan();
|
closeFan();
|
||||||
|
|
||||||
var invSlot = document.getElementById("id_inv_role_card");
|
// Show the tray handle — gamer confirmed a pick, tray animation about to run
|
||||||
if (invSlot) invSlot.appendChild(clean);
|
var trayWrap = document.getElementById("id_tray_wrap");
|
||||||
|
if (trayWrap) trayWrap.classList.remove("role-select-phase");
|
||||||
|
|
||||||
// Update the stack's taken-roles so the next openFan() filters correctly
|
// Immediately lock the stack — do not wait for WS turn_changed
|
||||||
var stack = document.querySelector(".card-stack[data-taken-roles]");
|
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||||
if (stack) {
|
if (stack) {
|
||||||
var current = stack.dataset.takenRoles;
|
stack.dataset.state = "ineligible";
|
||||||
stack.dataset.takenRoles = current ? current + "," + roleCode : roleCode;
|
stack.removeEventListener("click", openFan);
|
||||||
|
var current = stack.dataset.starterRoles;
|
||||||
|
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark seat as actively being claimed (glow state) and swap ban → check immediately
|
||||||
|
var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||||
|
if (activePos) {
|
||||||
|
activePos.classList.add('active');
|
||||||
|
var ban = activePos.querySelector('.fa-ban');
|
||||||
|
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately fade out the gate-slot circle for the current turn's slot
|
||||||
|
var activeSlot = stack ? stack.dataset.activeSlot : null;
|
||||||
|
if (activeSlot) {
|
||||||
|
var slotCircle = document.querySelector('.gate-slot[data-slot="' + activeSlot + '"]');
|
||||||
|
if (slotCircle) slotCircle.classList.add('role-assigned');
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = getSelectRoleUrl();
|
var url = getSelectRoleUrl();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
|
// Block handleTurnChanged immediately — WS turn_changed can arrive while
|
||||||
|
// the fetch is in-flight and must be deferred until our animation completes.
|
||||||
|
_animationPending = true;
|
||||||
|
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -50,20 +87,58 @@ var RoleSelect = (function () {
|
|||||||
"X-CSRFToken": getCsrf(),
|
"X-CSRFToken": getCsrf(),
|
||||||
},
|
},
|
||||||
body: "role=" + encodeURIComponent(roleCode),
|
body: "role=" + encodeURIComponent(roleCode),
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
// Server rejected (role already taken) — undo optimistic update
|
||||||
|
_animationPending = false;
|
||||||
|
if (stack) {
|
||||||
|
stack.dataset.starterRoles = stack.dataset.starterRoles
|
||||||
|
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
|
||||||
|
}
|
||||||
|
openFan();
|
||||||
|
} else {
|
||||||
|
// Animate the role card into the tray: open, arc-in, force-close.
|
||||||
|
// Any turn_changed that arrived while the fetch was in-flight is
|
||||||
|
// queued in _pendingTurnChange and will run after onComplete.
|
||||||
|
if (typeof Tray !== "undefined") {
|
||||||
|
setTimeout(function () {
|
||||||
|
Tray.placeCard(roleCode, function () {
|
||||||
|
// Swap ban → check, clear glow, mark seat as confirmed
|
||||||
|
var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||||
|
if (seatedPos) {
|
||||||
|
seatedPos.classList.remove('active');
|
||||||
|
seatedPos.classList.add('role-confirmed');
|
||||||
|
}
|
||||||
|
// Hold _animationPending through the post-tray pause so any
|
||||||
|
// turn_changed WS event that arrives now is still deferred.
|
||||||
|
setTimeout(function () {
|
||||||
|
_animationPending = false;
|
||||||
|
if (_pendingTurnChange) {
|
||||||
|
var ev = _pendingTurnChange;
|
||||||
|
_pendingTurnChange = null;
|
||||||
|
handleTurnChanged(ev);
|
||||||
|
}
|
||||||
|
}, _postTrayDelay);
|
||||||
|
});
|
||||||
|
}, _placeCardDelay);
|
||||||
|
} else {
|
||||||
|
_animationPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTakenRoles() {
|
function getStarterRoles() {
|
||||||
var stack = document.querySelector(".card-stack[data-taken-roles]");
|
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||||
if (!stack) return [];
|
if (!stack) return [];
|
||||||
var raw = stack.dataset.takenRoles;
|
var raw = stack.dataset.starterRoles;
|
||||||
return raw ? raw.split(",").map(function (s) { return s.trim(); }) : [];
|
return raw ? raw.split(",").map(function (s) { return s.trim(); }) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFan() {
|
function openFan() {
|
||||||
if (document.querySelector(".role-select-backdrop")) return;
|
if (document.querySelector(".role-select-backdrop")) return;
|
||||||
|
|
||||||
var taken = getTakenRoles();
|
var taken = getStarterRoles();
|
||||||
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
|
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
|
||||||
|
|
||||||
var backdrop = document.createElement("div");
|
var backdrop = document.createElement("div");
|
||||||
@@ -79,7 +154,7 @@ var RoleSelect = (function () {
|
|||||||
|
|
||||||
var back = document.createElement("div");
|
var back = document.createElement("div");
|
||||||
back.className = "card-back";
|
back.className = "card-back";
|
||||||
back.textContent = "?";
|
back.textContent = "ROLE";
|
||||||
|
|
||||||
var front = document.createElement("div");
|
var front = document.createElement("div");
|
||||||
front.className = "card-front";
|
front.className = "card-front";
|
||||||
@@ -92,11 +167,27 @@ var RoleSelect = (function () {
|
|||||||
card.classList.add("flipped");
|
card.classList.add("flipped");
|
||||||
});
|
});
|
||||||
card.addEventListener("mouseleave", function () {
|
card.addEventListener("mouseleave", function () {
|
||||||
card.classList.remove("flipped");
|
if (!card.classList.contains("guard-active")) {
|
||||||
|
card.classList.remove("flipped");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
card.addEventListener("click", function (e) {
|
card.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectRole(role.code, card);
|
card.classList.add("flipped");
|
||||||
|
card.classList.add("guard-active");
|
||||||
|
window.showGuard(
|
||||||
|
card,
|
||||||
|
"Start round 1<br>as " + role.name + " (" + role.code + ") …?",
|
||||||
|
function () { // confirm
|
||||||
|
card.classList.remove("guard-active");
|
||||||
|
selectRole(role.code);
|
||||||
|
},
|
||||||
|
function () { // dismiss (NVM / outside click)
|
||||||
|
card.classList.remove("guard-active");
|
||||||
|
card.classList.remove("flipped");
|
||||||
|
},
|
||||||
|
{ invertY: true } // modal grid: tooltip flies away from centre (upper→above, lower→below)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.appendChild(card);
|
modal.appendChild(card);
|
||||||
@@ -115,39 +206,103 @@ var RoleSelect = (function () {
|
|||||||
|
|
||||||
var _reload = function () { window.location.reload(); };
|
var _reload = function () { window.location.reload(); };
|
||||||
|
|
||||||
function handleRolesRevealed() {
|
function handleAllRolesFilled() {
|
||||||
|
var wrap = document.getElementById('id_pick_sigs_wrap');
|
||||||
|
if (wrap) wrap.style.display = '';
|
||||||
|
var stack = document.querySelector('.card-stack');
|
||||||
|
if (stack) stack.remove();
|
||||||
|
var trayWrap = document.getElementById('id_tray_wrap');
|
||||||
|
if (trayWrap) trayWrap.classList.remove('role-select-phase');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSigSelectStarted() {
|
||||||
_reload();
|
_reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTurnChanged(event) {
|
function handleTurnChanged(event) {
|
||||||
|
// If a placeCard animation is running, defer until it completes.
|
||||||
|
if (_animationPending) {
|
||||||
|
_pendingTurnChange = event;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var active = String(event.detail.active_slot);
|
var active = String(event.detail.active_slot);
|
||||||
|
|
||||||
// Update card-stack eligibility
|
// Force-close tray instantly so it never obscures the next player's card-stack.
|
||||||
|
// Also set the race flag so the fetch .then() doesn't re-open if it arrives late.
|
||||||
|
_turnChangedBeforeFetch = true;
|
||||||
|
if (typeof Tray !== "undefined") Tray.forceClose();
|
||||||
|
|
||||||
|
// Hide tray handle until the next player confirms their pick
|
||||||
|
var trayWrap = document.getElementById("id_tray_wrap");
|
||||||
|
if (trayWrap) trayWrap.classList.add("role-select-phase");
|
||||||
|
|
||||||
|
// Clear any stale .active glow from hex seats
|
||||||
|
document.querySelectorAll('.table-seat.active').forEach(function (p) {
|
||||||
|
p.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync seat icons from starter_roles so state persists without a reload
|
||||||
|
if (event.detail.starter_roles) {
|
||||||
|
var assignedRoles = event.detail.starter_roles;
|
||||||
|
document.querySelectorAll(".table-seat").forEach(function (seat) {
|
||||||
|
var role = seat.dataset.role;
|
||||||
|
if (assignedRoles.indexOf(role) !== -1) {
|
||||||
|
seat.classList.add("role-confirmed");
|
||||||
|
var ban = seat.querySelector(".fa-ban");
|
||||||
|
if (ban) { ban.classList.remove("fa-ban"); ban.classList.add("fa-circle-check"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Hide slot circles in turn order: slots 1..N done when N roles assigned
|
||||||
|
var assignedCount = assignedRoles.length;
|
||||||
|
document.querySelectorAll(".gate-slot[data-slot]").forEach(function (circle) {
|
||||||
|
if (parseInt(circle.dataset.slot, 10) <= assignedCount) {
|
||||||
|
circle.classList.add("role-assigned");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active slot on the card stack so selectRole() can read it
|
||||||
|
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||||
|
if (stack) stack.dataset.activeSlot = active;
|
||||||
|
|
||||||
var stack = document.querySelector(".card-stack[data-user-slots]");
|
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||||
if (stack) {
|
if (stack) {
|
||||||
|
// Sync starter-roles from server so the fan reflects actual DB state
|
||||||
|
if (event.detail.starter_roles) {
|
||||||
|
stack.dataset.starterRoles = event.detail.starter_roles.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update eligibility and ban icon together
|
||||||
var userSlots = stack.dataset.userSlots
|
var userSlots = stack.dataset.userSlots
|
||||||
? stack.dataset.userSlots.split(",") : [];
|
? stack.dataset.userSlots.split(",") : [];
|
||||||
if (userSlots.indexOf(active) !== -1) {
|
if (userSlots.indexOf(active) !== -1) {
|
||||||
stack.dataset.state = "eligible";
|
stack.dataset.state = "eligible";
|
||||||
|
var ban = stack.querySelector(".fa-ban");
|
||||||
|
if (ban) ban.remove();
|
||||||
stack.removeEventListener("click", openFan);
|
stack.removeEventListener("click", openFan);
|
||||||
stack.addEventListener("click", openFan);
|
stack.addEventListener("click", openFan);
|
||||||
} else {
|
} else {
|
||||||
stack.dataset.state = "ineligible";
|
stack.dataset.state = "ineligible";
|
||||||
stack.removeEventListener("click", openFan);
|
stack.removeEventListener("click", openFan);
|
||||||
|
if (!stack.querySelector(".fa-ban")) {
|
||||||
|
var icon = document.createElement("i");
|
||||||
|
icon.className = "fa-solid fa-ban";
|
||||||
|
stack.appendChild(icon);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move .active to the newly active seat
|
// Clear any stale seat glow (JS-only; glow is only during tray animation)
|
||||||
document.querySelectorAll(".table-seat.active").forEach(function (s) {
|
document.querySelectorAll(".table-seat.active").forEach(function (s) {
|
||||||
s.classList.remove("active");
|
s.classList.remove("active");
|
||||||
});
|
});
|
||||||
var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']");
|
|
||||||
if (activeSeat) activeSeat.classList.add("active");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("room:role_select_start", init);
|
window.addEventListener("room:role_select_start", init);
|
||||||
window.addEventListener("room:turn_changed", handleTurnChanged);
|
window.addEventListener("room:turn_changed", handleTurnChanged);
|
||||||
window.addEventListener("room:roles_revealed", handleRolesRevealed);
|
window.addEventListener("room:all_roles_filled", handleAllRolesFilled);
|
||||||
|
window.addEventListener("room:sig_select_started", handleSigSelectStarted);
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -156,8 +311,15 @@ var RoleSelect = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openFan: openFan,
|
openFan: openFan,
|
||||||
closeFan: closeFan,
|
closeFan: closeFan,
|
||||||
setReload: function (fn) { _reload = fn; },
|
setReload: function (fn) { _reload = fn; },
|
||||||
|
// Testing hook — resets animation-pause state between Jasmine specs
|
||||||
|
_testReset: function () {
|
||||||
|
_animationPending = false;
|
||||||
|
_pendingTurnChange = null;
|
||||||
|
_placeCardDelay = 0;
|
||||||
|
_postTrayDelay = 0;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -1,3 +1,111 @@
|
|||||||
|
(function () {
|
||||||
|
var SCENE_W = 360, SCENE_H = 300;
|
||||||
|
|
||||||
|
function scaleTable() {
|
||||||
|
var scene = document.querySelector('.room-table-scene');
|
||||||
|
var container = document.getElementById('id_game_table');
|
||||||
|
if (!scene || !container) return;
|
||||||
|
var w = container.clientWidth, h = container.clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
var scale = Math.min(w / SCENE_W, h / SCENE_H);
|
||||||
|
scene.style.transform = 'scale(' + scale + ')';
|
||||||
|
document.documentElement.style.setProperty('--table-scale', scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', scaleTable);
|
||||||
|
} else {
|
||||||
|
scaleTable();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', scaleTable);
|
||||||
|
window.addEventListener('resize:end', scaleTable);
|
||||||
|
}());
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
// Size the sig-select overlay so the card grid clears the tray handle
|
||||||
|
// (portrait: right strip 48px; landscape: bottom strip 48px) and any
|
||||||
|
// fixed gear/kit buttons that protrude further into the viewport.
|
||||||
|
// Mirrors the scaleTable() pattern — runs on load (after tray.js has
|
||||||
|
// positioned the tray) and on every resize.
|
||||||
|
function sizeSigModal() {
|
||||||
|
var overlay = document.querySelector('.sig-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
var vw = window.innerWidth;
|
||||||
|
var vh = window.innerHeight;
|
||||||
|
var rightInset = 0;
|
||||||
|
var bottomInset = 0;
|
||||||
|
|
||||||
|
var isLandscape = vw > vh;
|
||||||
|
|
||||||
|
// Tray handle: portrait → vertical strip on right; landscape → tray is easily
|
||||||
|
// dismissed, so skip the bottomInset calculation (would over-shrink the modal).
|
||||||
|
var trayHandle = document.getElementById('id_tray_handle');
|
||||||
|
if (trayHandle && !isLandscape) {
|
||||||
|
var hr = trayHandle.getBoundingClientRect();
|
||||||
|
if (hr.width < hr.height) {
|
||||||
|
// Portrait: handle strips the right edge
|
||||||
|
rightInset = vw - hr.left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gear / kit buttons: update right inset if near right edge.
|
||||||
|
// In landscape they sit at bottom-right but are also dismissible — skip bottomInset.
|
||||||
|
document.querySelectorAll('.room-page > .gear-btn').forEach(function (btn) {
|
||||||
|
var br = btn.getBoundingClientRect();
|
||||||
|
if (br.right > vw - 30) {
|
||||||
|
rightInset = Math.max(rightInset, vw - br.left);
|
||||||
|
}
|
||||||
|
if (!isLandscape && br.bottom > vh - 30) {
|
||||||
|
bottomInset = Math.max(bottomInset, vh - br.top);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Landscape: right clears gear/kit buttons; bottom is fixed 60px for the
|
||||||
|
// kit-bag handle strip — tray is ignored so the stage has room to breathe.
|
||||||
|
// At ≥1800px the right sidebar doubles to 8rem so clear 128px.
|
||||||
|
if (isLandscape) {
|
||||||
|
var xlBreak = vw >= 1800;
|
||||||
|
rightInset = Math.max(rightInset, xlBreak ? 128 : 64);
|
||||||
|
bottomInset = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.style.paddingRight = rightInset + 'px';
|
||||||
|
overlay.style.paddingBottom = bottomInset + 'px';
|
||||||
|
|
||||||
|
// Stage card: smaller of 40% stage width OR (80% stage height × 5/8 aspect).
|
||||||
|
// libsass can't handle cqw/cqh inside min(), so we compute it here.
|
||||||
|
var stageEl = overlay.querySelector('.sig-stage');
|
||||||
|
if (stageEl) {
|
||||||
|
var sw = stageEl.offsetWidth - 24; // subtract padding (0.75rem × 2)
|
||||||
|
var sh = stageEl.offsetHeight - 24;
|
||||||
|
if (sw > 0 && sh > 0) {
|
||||||
|
// Clamp between 90px (never tiny in landscape) and 160px (never
|
||||||
|
// dominant on very wide/tall viewports). In portrait, skip the
|
||||||
|
// floor so small modals still scale down naturally.
|
||||||
|
var cardW = Math.min(sw * 0.4, sh * 0.8 * 5 / 8, 160);
|
||||||
|
if (isLandscape) { cardW = Math.max(cardW, 90); }
|
||||||
|
overlay.style.setProperty('--sig-card-w', cardW + 'px');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', sizeSigModal);
|
||||||
|
window.addEventListener('resize', sizeSigModal);
|
||||||
|
window.addEventListener('resize:end', sizeSigModal);
|
||||||
|
}());
|
||||||
|
|
||||||
|
// Dispatch a custom 'resize:end' event 500 ms after the last 'resize' fires.
|
||||||
|
// scaleTable, sizeSigModal, and Tray._reposition all subscribe to it so they
|
||||||
|
// re-measure with settled viewport dimensions after rapid resize sequences.
|
||||||
|
(function () {
|
||||||
|
var t;
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(function () { window.dispatchEvent(new Event('resize:end')); }, 500);
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const roomPage = document.querySelector('.room-page');
|
const roomPage = document.querySelector('.room-page');
|
||||||
if (!roomPage) return;
|
if (!roomPage) return;
|
||||||
@@ -5,13 +113,16 @@
|
|||||||
const roomId = roomPage.dataset.roomId;
|
const roomId = roomPage.dataset.roomId;
|
||||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
||||||
|
window._roomSocket = ws; // exposed for sig-select.js hover broadcast
|
||||||
|
|
||||||
ws.onmessage = function (event) {
|
ws.onmessage = function (event) {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
window.dispatchEvent(new CustomEvent('room:' + data.type, { detail: data }));
|
window.dispatchEvent(new CustomEvent('room:' + data.type, { detail: data }));
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = function () {
|
ws.onclose = function (event) {
|
||||||
console.warn('Room WebSocket closed');
|
if (!event.wasClean) {
|
||||||
|
console.warn('Room WebSocket closed unexpectedly');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
|
|||||||
760
src/apps/epic/static/apps/epic/sig-select.js
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
var SigSelect = (function () {
|
||||||
|
// Polarity → three roles in fixed left/mid/right cursor order
|
||||||
|
var POLARITY_ROLES = {
|
||||||
|
levity: ['PC', 'NC', 'SC'],
|
||||||
|
gravity: ['BC', 'EC', 'AC'],
|
||||||
|
};
|
||||||
|
|
||||||
|
var overlay, deckGrid, stage, stageCard, statBlock;
|
||||||
|
var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl;
|
||||||
|
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
|
||||||
|
var reserveUrl, readyUrl, userRole, userPolarity;
|
||||||
|
|
||||||
|
var _isReady = false;
|
||||||
|
var _takeSigBtn = null;
|
||||||
|
var _glowTimer = null;
|
||||||
|
var _glowPeak = false;
|
||||||
|
var _countdownTimer = null;
|
||||||
|
var _countdownSecondsLeft = 0;
|
||||||
|
|
||||||
|
var _cautionData = [];
|
||||||
|
var _cautionIdx = 0;
|
||||||
|
|
||||||
|
var _focusedCardEl = null; // card currently shown in stage
|
||||||
|
var _reservedCardId = null; // card with active reservation
|
||||||
|
var _stageFrozen = false; // true after OK — stage locks on reserved card
|
||||||
|
var _requestInFlight = false;
|
||||||
|
|
||||||
|
var _floatingCursors = {}; // key: cardId+posClass → portal <i> element (hover)
|
||||||
|
var _reservedFloats = {}; // key: role → portal <i> element (thumbs-up, frozen)
|
||||||
|
var _cursorPortal = null;
|
||||||
|
|
||||||
|
function getCsrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _populateKeywordList(listEl, csv) {
|
||||||
|
var keywords = csv ? csv.split(',').filter(Boolean) : [];
|
||||||
|
listEl.innerHTML = keywords.map(function (k) {
|
||||||
|
return '<li>' + k.trim() + '</li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Caution tooltip ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCaution() {
|
||||||
|
if (_cautionData.length === 0) {
|
||||||
|
cautionEffect.innerHTML = '<em>Rival interactions pending.</em>';
|
||||||
|
cautionPrev.disabled = true;
|
||||||
|
cautionNext.disabled = true;
|
||||||
|
cautionIndexEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cautionEffect.innerHTML = _cautionData[_cautionIdx];
|
||||||
|
cautionPrev.disabled = (_cautionData.length <= 1);
|
||||||
|
cautionNext.disabled = (_cautionData.length <= 1);
|
||||||
|
cautionIndexEl.textContent = _cautionData.length > 1
|
||||||
|
? (_cautionIdx + 1) + ' / ' + _cautionData.length
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openCaution() {
|
||||||
|
if (!_focusedCardEl) return;
|
||||||
|
try {
|
||||||
|
_cautionData = JSON.parse(_focusedCardEl.dataset.cautions || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
_cautionData = [];
|
||||||
|
}
|
||||||
|
_cautionIdx = 0;
|
||||||
|
_renderCaution();
|
||||||
|
_flipBtn.classList.add('btn-disabled');
|
||||||
|
_cautionBtn.classList.add('btn-disabled');
|
||||||
|
_flipBtn.textContent = '\u00D7';
|
||||||
|
_cautionBtn.textContent = '\u00D7';
|
||||||
|
stage.classList.add('sig-caution-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _closeCaution() {
|
||||||
|
stage.classList.remove('sig-caution-open');
|
||||||
|
if (_flipBtn) {
|
||||||
|
_flipBtn.classList.remove('btn-disabled');
|
||||||
|
_cautionBtn.classList.remove('btn-disabled');
|
||||||
|
_flipBtn.textContent = _flipOrigLabel;
|
||||||
|
_cautionBtn.textContent = _cautionOrigLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStage(cardEl) {
|
||||||
|
if (_stageFrozen) return;
|
||||||
|
_closeCaution();
|
||||||
|
if (!cardEl) {
|
||||||
|
stageCard.style.display = 'none';
|
||||||
|
stage.classList.remove('sig-stage--active');
|
||||||
|
_focusedCardEl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_focusedCardEl = cardEl;
|
||||||
|
|
||||||
|
var rank = cardEl.dataset.cornerRank || '';
|
||||||
|
var icon = cardEl.dataset.suitIcon || '';
|
||||||
|
var group = cardEl.dataset.nameGroup || '';
|
||||||
|
var title = cardEl.dataset.nameTitle || '';
|
||||||
|
var arcana= cardEl.dataset.arcana || '';
|
||||||
|
var corr = cardEl.dataset.correspondence || '';
|
||||||
|
|
||||||
|
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
|
||||||
|
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||||
|
if (icon) {
|
||||||
|
el.className = 'fa-solid ' + icon + ' stage-suit-icon';
|
||||||
|
el.style.display = '';
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stageCard.querySelector('.fan-card-name-group').textContent = group;
|
||||||
|
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||||
|
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
|
||||||
|
|
||||||
|
var qualifier = userPolarity === 'levity' ? 'Leavened' : 'Graven';
|
||||||
|
var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
|
||||||
|
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
|
||||||
|
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||||
|
stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier;
|
||||||
|
stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : '';
|
||||||
|
|
||||||
|
// Populate stat block keyword faces and reset to upright
|
||||||
|
statBlock.classList.remove('is-reversed');
|
||||||
|
_populateKeywordList(
|
||||||
|
statBlock.querySelector('#id_stat_keywords_upright'),
|
||||||
|
cardEl.dataset.keywordsUpright
|
||||||
|
);
|
||||||
|
_populateKeywordList(
|
||||||
|
statBlock.querySelector('#id_stat_keywords_reversed'),
|
||||||
|
cardEl.dataset.keywordsReversed
|
||||||
|
);
|
||||||
|
|
||||||
|
stageCard.style.display = '';
|
||||||
|
stage.classList.add('sig-stage--active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Focus a card (click/tap) — shows OK overlay on the card ──────────
|
||||||
|
|
||||||
|
function focusCard(cardEl) {
|
||||||
|
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||||
|
if (c !== cardEl) c.classList.remove('sig-focused');
|
||||||
|
});
|
||||||
|
cardEl.classList.add('sig-focused');
|
||||||
|
updateStage(cardEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hover events ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onCardEnter(e) {
|
||||||
|
var card = e.currentTarget;
|
||||||
|
if (!_stageFrozen) updateStage(card);
|
||||||
|
sendHover(card.dataset.cardId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardLeave(e) {
|
||||||
|
if (!_stageFrozen) updateStage(null);
|
||||||
|
sendHover(e.currentTarget.dataset.cardId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reserve / release ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function doReserve(cardEl) {
|
||||||
|
if (_requestInFlight) return;
|
||||||
|
var cardId = cardEl.dataset.cardId;
|
||||||
|
_requestInFlight = true;
|
||||||
|
fetch(reserveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: 'action=reserve&card_id=' + encodeURIComponent(cardId),
|
||||||
|
}).then(function (res) {
|
||||||
|
_requestInFlight = false;
|
||||||
|
if (res.ok) applyReservation(cardId, userRole, true);
|
||||||
|
}).catch(function () { _requestInFlight = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doRelease() {
|
||||||
|
if (_requestInFlight || !_reservedCardId) return;
|
||||||
|
var cardId = _reservedCardId;
|
||||||
|
_requestInFlight = true;
|
||||||
|
fetch(reserveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: 'action=release&card_id=' + encodeURIComponent(cardId),
|
||||||
|
}).then(function (res) {
|
||||||
|
_requestInFlight = false;
|
||||||
|
if (res.ok) applyReservation(cardId, userRole, false);
|
||||||
|
}).catch(function () { _requestInFlight = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply reservation state (local + from WS) ─────────────────────────
|
||||||
|
|
||||||
|
function _placeReservedFloat(cardId, cardEl, role) {
|
||||||
|
// Remove any pre-existing reserved float for this role (e.g. page-load replay)
|
||||||
|
if (_reservedFloats[role]) { _reservedFloats[role].remove(); }
|
||||||
|
|
||||||
|
// Retire ALL hover floats for this role — may be on a different card than reserved
|
||||||
|
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||||
|
var idx = roles.indexOf(role);
|
||||||
|
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
if (key.slice(-posClass.length) === posClass) {
|
||||||
|
_floatingCursors[key].remove();
|
||||||
|
var hCid = key.slice(0, key.length - posClass.length);
|
||||||
|
var hEl = deckGrid.querySelector('.sig-card[data-card-id="' + hCid + '"]');
|
||||||
|
if (hEl) {
|
||||||
|
var a = hEl.querySelector('.sig-cursor' + posClass);
|
||||||
|
if (a) a.classList.remove('active');
|
||||||
|
}
|
||||||
|
delete _floatingCursors[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var xFractions = [0.15, 0.5, 0.85];
|
||||||
|
var fc = document.createElement('i');
|
||||||
|
fc.className = 'fa-solid fa-thumbs-up sig-cursor-float sig-cursor-float--reserved';
|
||||||
|
fc.dataset.role = role;
|
||||||
|
fc.style.left = (rect.left + rect.width * xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
_ensureCursorPortal().appendChild(fc);
|
||||||
|
_reservedFloats[role] = fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReservation(cardId, role, reserved) {
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
|
||||||
|
if (reserved) {
|
||||||
|
cardEl.dataset.reservedBy = role;
|
||||||
|
cardEl.classList.add('sig-reserved');
|
||||||
|
if (role === userRole) {
|
||||||
|
_reservedCardId = cardId;
|
||||||
|
cardEl.classList.add('sig-reserved--own');
|
||||||
|
cardEl.classList.remove('sig-focused');
|
||||||
|
// Freeze stage on this card (temporarily unfreeze to populate it)
|
||||||
|
_stageFrozen = false;
|
||||||
|
updateStage(cardEl);
|
||||||
|
_stageFrozen = true;
|
||||||
|
stage.classList.add('sig-stage--frozen');
|
||||||
|
_showTakeSigBtn();
|
||||||
|
}
|
||||||
|
// Thumbs-up float for all reservations — own role sees their own indicator too
|
||||||
|
_placeReservedFloat(cardId, cardEl, role);
|
||||||
|
} else {
|
||||||
|
delete cardEl.dataset.reservedBy;
|
||||||
|
cardEl.classList.remove('sig-reserved', 'sig-reserved--own');
|
||||||
|
if (role === userRole) {
|
||||||
|
_reservedCardId = null;
|
||||||
|
_stageFrozen = false;
|
||||||
|
stage.classList.remove('sig-stage--frozen');
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
}
|
||||||
|
// Remove thumbs-up float for all releases — own role included
|
||||||
|
if (_reservedFloats[role]) {
|
||||||
|
_reservedFloats[role].remove();
|
||||||
|
delete _reservedFloats[role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply hover cursor (WS only — own hover is CSS :hover) ────────────
|
||||||
|
//
|
||||||
|
// Cursor icons are portaled to document root so they escape overflow/clip
|
||||||
|
// contexts in the deck grid. The in-card anchor elements only carry the
|
||||||
|
// .active class (for test assertions and the :has() z-index rule).
|
||||||
|
|
||||||
|
function _ensureCursorPortal() {
|
||||||
|
if (!_cursorPortal || !document.body.contains(_cursorPortal)) {
|
||||||
|
_cursorPortal = document.getElementById('id_sig_cursor_portal');
|
||||||
|
if (!_cursorPortal) {
|
||||||
|
_cursorPortal = document.createElement('div');
|
||||||
|
_cursorPortal.id = 'id_sig_cursor_portal';
|
||||||
|
document.body.appendChild(_cursorPortal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _cursorPortal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHover(cardId, role, active) {
|
||||||
|
if (role === userRole) return;
|
||||||
|
if (_reservedFloats[role]) return; // role already has thumbs-up — ignore hover
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
|
||||||
|
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||||
|
var idx = roles.indexOf(role);
|
||||||
|
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||||
|
var anchor = cardEl.querySelector('.sig-cursor' + posClass);
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
var key = cardId + posClass;
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
anchor.classList.add('active'); // kept for test assertions + :has() z-index
|
||||||
|
|
||||||
|
// Place a fixed-position clone in the portal, positioned from card bounds
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var xFractions = [0.15, 0.5, 0.85];
|
||||||
|
var fc = document.createElement('i');
|
||||||
|
fc.className = 'fa-solid fa-hand-pointer sig-cursor-float';
|
||||||
|
fc.dataset.role = role;
|
||||||
|
fc.style.left = (rect.left + rect.width * xFractions[idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
_ensureCursorPortal().appendChild(fc);
|
||||||
|
_floatingCursors[key] = fc;
|
||||||
|
} else {
|
||||||
|
anchor.classList.remove('active');
|
||||||
|
if (_floatingCursors[key]) {
|
||||||
|
_floatingCursors[key].remove();
|
||||||
|
delete _floatingCursors[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reposition floats after resize ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Both reserved (thumbs-up) and hover (hand-pointer) floats are stamped with
|
||||||
|
// fixed pixel coords at placement time. When the viewport changes size the
|
||||||
|
// cards reflow but the icons stay put. Re-measure from the card's current
|
||||||
|
// bounding rect and update left/top in-place.
|
||||||
|
|
||||||
|
var _posClasses = ['--left', '--mid', '--right'];
|
||||||
|
var _xFractions = [0.15, 0.5, 0.85];
|
||||||
|
|
||||||
|
function _repositionFloats() {
|
||||||
|
if (!deckGrid) return;
|
||||||
|
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||||
|
|
||||||
|
Object.keys(_reservedFloats).forEach(function (role) {
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-reserved-by="' + role + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var idx = roles.indexOf(role);
|
||||||
|
var fc = _reservedFloats[role];
|
||||||
|
fc.style.left = (rect.left + rect.width * _xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
var posClass = _posClasses.find(function (p) {
|
||||||
|
return key.slice(-p.length) === p;
|
||||||
|
});
|
||||||
|
if (!posClass) return;
|
||||||
|
var cardId = key.slice(0, key.length - posClass.length);
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var idx = _posClasses.indexOf(posClass);
|
||||||
|
var fc = _floatingCursors[key];
|
||||||
|
fc.style.left = (rect.left + rect.width * _xFractions[idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TAKE SIG / WAIT NVM button ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function _onTakeSigClick() {
|
||||||
|
if (_isReady) {
|
||||||
|
var body = 'action=unready';
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
body += '&seconds_remaining=' + _countdownSecondsLeft;
|
||||||
|
}
|
||||||
|
fetch(readyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: body,
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
_isReady = false;
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
if (_takeSigBtn) _takeSigBtn.style.fontSize = '';
|
||||||
|
}
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = 'TAKE SIG';
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_stopCountdownGlow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fetch(readyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: 'action=ready',
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
_isReady = true;
|
||||||
|
// countdown_start WS may arrive before this response for the
|
||||||
|
// gamer who triggered the countdown — don't clobber the numeral.
|
||||||
|
if (_countdownTimer === null) {
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showTakeSigBtn() {
|
||||||
|
if (_takeSigBtn || !stage) return;
|
||||||
|
_takeSigBtn = document.createElement('button');
|
||||||
|
_takeSigBtn.id = 'id_take_sig_btn';
|
||||||
|
_takeSigBtn.className = 'btn btn-primary sig-take-sig-btn';
|
||||||
|
_takeSigBtn.type = 'button';
|
||||||
|
_takeSigBtn.textContent = 'TAKE SIG';
|
||||||
|
_takeSigBtn.addEventListener('click', _onTakeSigClick);
|
||||||
|
stage.appendChild(_takeSigBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startWaitNoGlow() {
|
||||||
|
if (_glowTimer !== null) return;
|
||||||
|
_glowPeak = false;
|
||||||
|
_glowTimer = setInterval(function () {
|
||||||
|
if (!_takeSigBtn) { _stopWaitNoGlow(); return; }
|
||||||
|
_glowPeak = !_glowPeak;
|
||||||
|
if (_glowPeak) {
|
||||||
|
_takeSigBtn.classList.add('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow =
|
||||||
|
'0 0 0.8rem 0.2rem rgba(var(--terOr), 0.75), ' +
|
||||||
|
'0 0 2rem 0.4rem rgba(var(--terOr), 0.35)';
|
||||||
|
} else {
|
||||||
|
_takeSigBtn.classList.remove('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopWaitNoGlow() {
|
||||||
|
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.classList.remove('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
_glowPeak = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startCountdownGlow() {
|
||||||
|
if (_glowTimer !== null) return;
|
||||||
|
_glowPeak = false;
|
||||||
|
_glowTimer = setInterval(function () {
|
||||||
|
if (!_takeSigBtn) { _stopCountdownGlow(); return; }
|
||||||
|
_glowPeak = !_glowPeak;
|
||||||
|
if (_glowPeak) {
|
||||||
|
_takeSigBtn.classList.add('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow =
|
||||||
|
'0 0 0.8rem 0.2rem rgba(var(--terRd), 0.75), ' +
|
||||||
|
'0 0 2rem 0.4rem rgba(var(--terRd), 0.35)';
|
||||||
|
} else {
|
||||||
|
_takeSigBtn.classList.remove('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopCountdownGlow() {
|
||||||
|
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.classList.remove('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
_glowPeak = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideTakeSigBtn() {
|
||||||
|
if (!_takeSigBtn) return;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_takeSigBtn.removeEventListener('click', _onTakeSigClick);
|
||||||
|
_takeSigBtn.remove();
|
||||||
|
_takeSigBtn = null;
|
||||||
|
_isReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polarity countdown ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _showCountdown(seconds) {
|
||||||
|
_countdownSecondsLeft = seconds;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.textContent = _countdownSecondsLeft;
|
||||||
|
_takeSigBtn.style.fontSize = '2em';
|
||||||
|
}
|
||||||
|
_startCountdownGlow();
|
||||||
|
if (_countdownTimer !== null) clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = setInterval(function () {
|
||||||
|
_countdownSecondsLeft -= 1;
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = _countdownSecondsLeft;
|
||||||
|
if (_countdownSecondsLeft <= 0) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
_stopCountdownGlow(); // server drives the transition via Celery task
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideCountdown() {
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
}
|
||||||
|
_stopCountdownGlow();
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.style.fontSize = '';
|
||||||
|
if (_isReady) {
|
||||||
|
// Countdown cancelled by another gamer — restore WAIT NVM state
|
||||||
|
_takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlay dismiss + waiting message ─────────────────────────────────
|
||||||
|
|
||||||
|
function _dismissSigOverlay() {
|
||||||
|
_hideCountdown();
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
var backdrop = document.querySelector('.sig-backdrop');
|
||||||
|
if (backdrop) backdrop.remove();
|
||||||
|
if (overlay) { overlay.remove(); overlay = null; }
|
||||||
|
// Remove all floating cursors (hover + thumbs-up) from the portal
|
||||||
|
Object.keys(_reservedFloats).forEach(function (role) {
|
||||||
|
_reservedFloats[role].remove();
|
||||||
|
});
|
||||||
|
_reservedFloats = {};
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
_floatingCursors[key].remove();
|
||||||
|
});
|
||||||
|
_floatingCursors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showWaitingMsg(pendingPolarity) {
|
||||||
|
if (document.getElementById('id_hex_waiting_msg')) return;
|
||||||
|
var msg = document.createElement('p');
|
||||||
|
msg.id = 'id_hex_waiting_msg';
|
||||||
|
msg.textContent = pendingPolarity === 'gravity'
|
||||||
|
? 'Gravity settling . . .'
|
||||||
|
: 'Levity appraising . . .';
|
||||||
|
var center = document.querySelector('.table-center');
|
||||||
|
if (center) center.appendChild(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WS events ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.addEventListener('room:sig_reserved', function (e) {
|
||||||
|
if (!deckGrid) return;
|
||||||
|
applyReservation(String(e.detail.card_id), e.detail.role, e.detail.reserved);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:sig_hover', function (e) {
|
||||||
|
if (!deckGrid) return;
|
||||||
|
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:countdown_start', function (e) {
|
||||||
|
if (!overlay) return;
|
||||||
|
_showCountdown(e.detail.seconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:countdown_cancel', function (e) {
|
||||||
|
_hideCountdown();
|
||||||
|
_countdownSecondsLeft = e.detail.seconds_remaining;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:polarity_room_done', function (e) {
|
||||||
|
if (!overlay) return;
|
||||||
|
if (e.detail.polarity !== userPolarity) return;
|
||||||
|
var pendingPolarity = userPolarity === 'levity' ? 'gravity' : 'levity';
|
||||||
|
_dismissSigOverlay();
|
||||||
|
_showWaitingMsg(pendingPolarity);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:pick_sky_available', function () {
|
||||||
|
var msg = document.getElementById('id_hex_waiting_msg');
|
||||||
|
if (msg) msg.remove();
|
||||||
|
var btn = document.getElementById('id_pick_sky_btn');
|
||||||
|
if (btn) btn.style.display = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize:end', _repositionFloats);
|
||||||
|
|
||||||
|
// ── WS send ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sendHover(cardId, active) {
|
||||||
|
if (!window._roomSocket || window._roomSocket.readyState !== WebSocket.OPEN) return;
|
||||||
|
window._roomSocket.send(JSON.stringify({
|
||||||
|
type: 'sig_hover', card_id: cardId, role: userRole, active: active,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
overlay = document.querySelector('.sig-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
deckGrid = overlay.querySelector('.sig-deck-grid');
|
||||||
|
stage = overlay.querySelector('.sig-stage');
|
||||||
|
stageCard = stage.querySelector('.sig-stage-card');
|
||||||
|
statBlock = stage.querySelector('.sig-stat-block');
|
||||||
|
|
||||||
|
_flipBtn = statBlock.querySelector('.sig-flip-btn');
|
||||||
|
_cautionBtn = statBlock.querySelector('.sig-caution-btn');
|
||||||
|
_flipOrigLabel = _flipBtn.textContent;
|
||||||
|
_cautionOrigLabel = _cautionBtn.textContent;
|
||||||
|
|
||||||
|
_flipBtn.addEventListener('click', function () {
|
||||||
|
if (_flipBtn.classList.contains('btn-disabled')) return;
|
||||||
|
statBlock.classList.toggle('is-reversed');
|
||||||
|
});
|
||||||
|
|
||||||
|
cautionEl = stage.querySelector('.sig-caution-tooltip');
|
||||||
|
cautionEffect = cautionEl.querySelector('.sig-caution-effect');
|
||||||
|
cautionPrev = statBlock.querySelector('.sig-caution-prev');
|
||||||
|
cautionNext = statBlock.querySelector('.sig-caution-next');
|
||||||
|
cautionIndexEl = cautionEl.querySelector('.sig-caution-index');
|
||||||
|
|
||||||
|
// Clicking the tooltip (not nav buttons) dismisses it
|
||||||
|
cautionEl.addEventListener('click', function () {
|
||||||
|
_closeCaution();
|
||||||
|
});
|
||||||
|
|
||||||
|
_cautionBtn.addEventListener('click', function () {
|
||||||
|
if (_cautionBtn.classList.contains('btn-disabled')) return;
|
||||||
|
stage.classList.contains('sig-caution-open') ? _closeCaution() : _openCaution();
|
||||||
|
});
|
||||||
|
cautionPrev.addEventListener('click', function () {
|
||||||
|
_cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length;
|
||||||
|
_renderCaution();
|
||||||
|
});
|
||||||
|
cautionNext.addEventListener('click', function () {
|
||||||
|
_cautionIdx = (_cautionIdx + 1) % _cautionData.length;
|
||||||
|
_renderCaution();
|
||||||
|
});
|
||||||
|
|
||||||
|
reserveUrl = overlay.dataset.reserveUrl;
|
||||||
|
readyUrl = overlay.dataset.readyUrl;
|
||||||
|
|
||||||
|
userRole = overlay.dataset.userRole;
|
||||||
|
userPolarity= overlay.dataset.polarity;
|
||||||
|
|
||||||
|
// PICK SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available
|
||||||
|
var pickSkyBtn = document.getElementById('id_pick_sky_btn');
|
||||||
|
if (pickSkyBtn) {
|
||||||
|
pickSkyBtn.addEventListener('click', function () {
|
||||||
|
if (typeof Tray !== 'undefined') Tray.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore reservations from server-rendered JSON (page-load state).
|
||||||
|
// Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered
|
||||||
|
// in room.js before this script) has already applied paddingBottom and
|
||||||
|
// --sig-card-w before _placeReservedFloat calls getBoundingClientRect().
|
||||||
|
try {
|
||||||
|
var existing = JSON.parse(overlay.dataset.reservations || '{}');
|
||||||
|
if (Object.keys(existing).length) {
|
||||||
|
var _replayReservations = function () {
|
||||||
|
Object.keys(existing).forEach(function (cardId) {
|
||||||
|
applyReservation(cardId, existing[cardId], true);
|
||||||
|
});
|
||||||
|
// Restore WAIT NVM state if gamer was already ready before page load
|
||||||
|
if (overlay.dataset.ready === 'true' && _takeSigBtn) {
|
||||||
|
_isReady = true;
|
||||||
|
_takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
_replayReservations();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', _replayReservations, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* malformed JSON — ignore */ }
|
||||||
|
|
||||||
|
// Hover: update stage preview + broadcast cursor
|
||||||
|
deckGrid.querySelectorAll('.sig-card').forEach(function (card) {
|
||||||
|
card.addEventListener('mouseenter', onCardEnter);
|
||||||
|
card.addEventListener('mouseleave', onCardLeave);
|
||||||
|
card.addEventListener('touchstart', function (e) {
|
||||||
|
var card = e.currentTarget;
|
||||||
|
if (_reservedCardId) return; // locked until NVM — no preventDefault either
|
||||||
|
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||||
|
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||||
|
if (reservedByOther || isOwnReserved) return;
|
||||||
|
// If the tap is on the OK button, let the synthetic click fire normally
|
||||||
|
if (e.target.closest('.sig-ok-btn')) return;
|
||||||
|
focusCard(card);
|
||||||
|
e.preventDefault(); // prevent ghost click on card body
|
||||||
|
}, { passive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch outside the grid — dismiss stage preview (unfocused state only).
|
||||||
|
// Card touchstart doesn't stop propagation, so we guard with closest().
|
||||||
|
overlay.addEventListener('touchstart', function (e) {
|
||||||
|
if (_stageFrozen || !_focusedCardEl) return;
|
||||||
|
if (e.target.closest('.sig-deck-grid')) return;
|
||||||
|
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||||
|
c.classList.remove('sig-focused');
|
||||||
|
});
|
||||||
|
updateStage(null);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Click delegation: card body → focus (shows OK); OK/NVM buttons → reserve/release
|
||||||
|
deckGrid.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.sig-ok-btn')) {
|
||||||
|
if (_reservedCardId) return; // already holding — must NVM first
|
||||||
|
var card = e.target.closest('.sig-card');
|
||||||
|
if (card) doReserve(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('.sig-nvm-btn')) {
|
||||||
|
doRelease();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var card = e.target.closest('.sig-card');
|
||||||
|
if (!card) return;
|
||||||
|
if (_reservedCardId) return; // locked until NVM
|
||||||
|
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||||
|
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||||
|
if (reservedByOther || isOwnReserved) return;
|
||||||
|
focusCard(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test API ──────────────────────────────────────────────────────────
|
||||||
|
return {
|
||||||
|
_testInit: function () {
|
||||||
|
_focusedCardEl = null;
|
||||||
|
_reservedCardId = null;
|
||||||
|
_stageFrozen = false;
|
||||||
|
_requestInFlight = false;
|
||||||
|
_cautionData = [];
|
||||||
|
_cautionIdx = 0;
|
||||||
|
Object.keys(_floatingCursors).forEach(function (k) { _floatingCursors[k].remove(); });
|
||||||
|
_floatingCursors = {};
|
||||||
|
Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); });
|
||||||
|
_reservedFloats = {};
|
||||||
|
_cursorPortal = null;
|
||||||
|
_isReady = false;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
_hideCountdown();
|
||||||
|
_countdownSecondsLeft = 0;
|
||||||
|
init();
|
||||||
|
},
|
||||||
|
_setFrozen: function (v) { _stageFrozen = v; },
|
||||||
|
_setReservedCardId: function (id) { _reservedCardId = id; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
523
src/apps/epic/static/apps/epic/tray.js
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
var Tray = (function () {
|
||||||
|
var _open = false;
|
||||||
|
// Fallback timeout (ms) after close() in placeCard in case transitionend
|
||||||
|
// never fires (e.g. CSS transitions disabled). Zeroed by reset() for tests.
|
||||||
|
var _closeTransitionMs = 600;
|
||||||
|
var _dragStartX = null;
|
||||||
|
var _dragStartY = null;
|
||||||
|
var _dragStartLeft = null;
|
||||||
|
var _dragStartTop = null;
|
||||||
|
var _dragHandled = false;
|
||||||
|
|
||||||
|
var _wrap = null;
|
||||||
|
var _btn = null;
|
||||||
|
var _tray = null;
|
||||||
|
var _grid = null;
|
||||||
|
|
||||||
|
// Role code → scrawl SVG name mapping for tray card display.
|
||||||
|
var _ROLE_SCRAWL = {
|
||||||
|
PC: 'Player', NC: 'Narrator', EC: 'Economist',
|
||||||
|
SC: 'Shepherd', AC: 'Alchemist', BC: 'Builder'
|
||||||
|
};
|
||||||
|
var _roleIconsUrl = null;
|
||||||
|
|
||||||
|
// Portrait bounds (X axis)
|
||||||
|
var _minLeft = 0;
|
||||||
|
var _maxLeft = 0;
|
||||||
|
|
||||||
|
// Landscape bounds (Y axis): _maxTop = closed (more negative), _minTop = open
|
||||||
|
var _minTop = 0;
|
||||||
|
var _maxTop = 0;
|
||||||
|
|
||||||
|
// Stored so reset() can remove them
|
||||||
|
var _onDocMove = null;
|
||||||
|
var _onDocUp = null;
|
||||||
|
var _onBtnClick = null;
|
||||||
|
var _closePendingHide = null; // portrait: pending display:none after slide
|
||||||
|
|
||||||
|
function _cancelPendingHide() {
|
||||||
|
if (_closePendingHide && _wrap) {
|
||||||
|
_wrap.removeEventListener('transitionend', _closePendingHide);
|
||||||
|
}
|
||||||
|
_closePendingHide = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing hook — null means use real window dimensions
|
||||||
|
var _landscapeOverride = null;
|
||||||
|
|
||||||
|
function _isLandscape() {
|
||||||
|
if (_landscapeOverride !== null) return _landscapeOverride;
|
||||||
|
return window.innerWidth > window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the square cell size from the tray's interior dimension and set
|
||||||
|
// --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
|
||||||
|
// Portrait: divide height / 8. Landscape: divide width / 8.
|
||||||
|
// In portrait the tray may be display:none; we show it with visibility:hidden
|
||||||
|
// briefly so clientHeight returns a real value, then restore display:none.
|
||||||
|
function _computeCellSize() {
|
||||||
|
if (!_tray) return;
|
||||||
|
var size;
|
||||||
|
if (_isLandscape()) {
|
||||||
|
size = Math.floor(_tray.clientWidth / 8);
|
||||||
|
} else {
|
||||||
|
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
|
||||||
|
if (wasHidden) {
|
||||||
|
_tray.style.visibility = 'hidden';
|
||||||
|
_tray.style.display = 'grid';
|
||||||
|
}
|
||||||
|
size = Math.floor(_tray.clientHeight / 8);
|
||||||
|
if (wasHidden) {
|
||||||
|
_tray.style.display = 'none';
|
||||||
|
_tray.style.visibility = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (size > 0) {
|
||||||
|
_tray.style.setProperty('--tray-cell-size', size + 'px');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _computeBounds() {
|
||||||
|
if (_isLandscape()) {
|
||||||
|
// Landscape: the wrap slides on the Y axis.
|
||||||
|
// Structure (column-reverse): tray above, handle below.
|
||||||
|
// Wrap height is fixed to gearBtnTop so the handle bottom always
|
||||||
|
// meets the gear button when open. Tray is flex:1 and fills the rest.
|
||||||
|
// Open: wrap top = 0 (pinned to viewport top).
|
||||||
|
// Closed: wrap top = -(gearBtnTop - handleH) = tray fully above viewport.
|
||||||
|
var gearBtn = document.getElementById('id_gear_btn');
|
||||||
|
var gearBtnTop = window.innerHeight;
|
||||||
|
if (gearBtn) {
|
||||||
|
gearBtnTop = Math.round(gearBtn.getBoundingClientRect().top);
|
||||||
|
}
|
||||||
|
var handleH = (_btn && _btn.offsetHeight) || 48;
|
||||||
|
|
||||||
|
// Pin wrap height so handle bottom = gear btn top when open.
|
||||||
|
if (_wrap) _wrap.style.height = gearBtnTop + 'px';
|
||||||
|
|
||||||
|
// Open: wrap pinned to viewport top.
|
||||||
|
_minTop = 0;
|
||||||
|
|
||||||
|
// Closed: tray hidden above viewport, handle visible at y=0.
|
||||||
|
_maxTop = -(gearBtnTop - handleH);
|
||||||
|
} else {
|
||||||
|
// Portrait: wrap width = full viewport; handle parks at right edge.
|
||||||
|
var handleW = _btn.offsetWidth || 48;
|
||||||
|
if (_wrap) _wrap.style.width = window.innerWidth + 'px';
|
||||||
|
_minLeft = 0;
|
||||||
|
_maxLeft = window.innerWidth - handleW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyVerticalBounds() {
|
||||||
|
// Portrait only: nudge wrap top/bottom to avoid overlapping nav/footer bars.
|
||||||
|
var nav = document.querySelector('nav');
|
||||||
|
var footer = document.querySelector('footer');
|
||||||
|
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
var inset = Math.round(rem * 0.125);
|
||||||
|
if (nav) {
|
||||||
|
var nb = nav.getBoundingClientRect();
|
||||||
|
if (nb.width > nb.height && nb.bottom < window.innerHeight * 0.4) {
|
||||||
|
_wrap.style.top = (Math.round(nb.bottom) + inset) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (footer) {
|
||||||
|
var fb = footer.getBoundingClientRect();
|
||||||
|
if (fb.width > fb.height && fb.top > window.innerHeight * 0.6) {
|
||||||
|
_wrap.style.bottom = (window.innerHeight - Math.round(fb.top) + inset) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (_open) return;
|
||||||
|
_cancelPendingHide(); // abort any in-flight portrait close animation
|
||||||
|
_open = true;
|
||||||
|
// Portrait only: toggle tray display.
|
||||||
|
// Landscape: tray is always display:block; wrap position controls visibility.
|
||||||
|
if (!_isLandscape() && _tray) _tray.style.display = 'grid';
|
||||||
|
if (_btn) _btn.classList.add('open');
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
_wrap.style.top = _minTop + 'px';
|
||||||
|
} else {
|
||||||
|
_wrap.style.left = _minLeft + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!_open) return;
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
_wrap.style.top = _maxTop + 'px';
|
||||||
|
// Snap after the slide completes.
|
||||||
|
_closePendingHide = function (e) {
|
||||||
|
if (e.propertyName !== 'top') return;
|
||||||
|
if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide);
|
||||||
|
_closePendingHide = null;
|
||||||
|
_snapWrap();
|
||||||
|
};
|
||||||
|
_wrap.addEventListener('transitionend', _closePendingHide);
|
||||||
|
} else {
|
||||||
|
_wrap.style.left = _maxLeft + 'px';
|
||||||
|
// Snap first (tray still visible so it peeks), then hide tray.
|
||||||
|
_closePendingHide = function (e) {
|
||||||
|
if (e.propertyName !== 'left') return;
|
||||||
|
if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide);
|
||||||
|
_closePendingHide = null;
|
||||||
|
_snapWrap(function () {
|
||||||
|
if (!_open && _tray) _tray.style.display = 'none';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
_wrap.addEventListener('transitionend', _closePendingHide);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen() { return _open; }
|
||||||
|
|
||||||
|
// forceClose() — instant, no animation. Used by server-driven events
|
||||||
|
// (e.g. turn_changed) where the tray must be out of the way immediately.
|
||||||
|
function forceClose() {
|
||||||
|
_cancelPendingHide();
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) _wrap.classList.remove('wobble', 'snap');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.top = _maxTop + 'px';
|
||||||
|
void _wrap.offsetWidth;
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_tray) _tray.style.display = 'none';
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.left = _maxLeft + 'px';
|
||||||
|
void _wrap.offsetWidth;
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _snapWrap(onDone) {
|
||||||
|
if (!_wrap) return;
|
||||||
|
_wrap.classList.add('snap');
|
||||||
|
_wrap.addEventListener('animationend', function handler() {
|
||||||
|
if (_wrap) _wrap.classList.remove('snap');
|
||||||
|
_wrap.removeEventListener('animationend', handler);
|
||||||
|
if (onDone) onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wobble() {
|
||||||
|
if (!_wrap) return;
|
||||||
|
// Portrait: show tray so it peeks in during the translateX animation,
|
||||||
|
// then re-hide it if the tray is still closed when the animation ends.
|
||||||
|
if (!_isLandscape() && _tray) _tray.style.display = 'grid';
|
||||||
|
_wrap.classList.add('wobble');
|
||||||
|
_wrap.addEventListener('animationend', function handler() {
|
||||||
|
_wrap.classList.remove('wobble');
|
||||||
|
_wrap.removeEventListener('animationend', handler);
|
||||||
|
if (!_isLandscape() && !_open && _tray) _tray.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// _arcIn — add .arc-in to cardEl, wait for animationend, remove it, call onComplete.
|
||||||
|
function _arcIn(cardEl, onComplete) {
|
||||||
|
cardEl.classList.add('arc-in');
|
||||||
|
cardEl.addEventListener('animationend', function handler() {
|
||||||
|
cardEl.removeEventListener('animationend', handler);
|
||||||
|
cardEl.classList.remove('arc-in');
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeCard(roleCode, onComplete) — mark the first tray cell with the role,
|
||||||
|
// open the tray, arc-in the cell, then animated-close. Calls onComplete after
|
||||||
|
// the close slide finishes (transitionend), with a fallback timeout in case
|
||||||
|
// CSS transitions are disabled (e.g. test environments).
|
||||||
|
// The grid always contains exactly 8 .tray-cell elements (from the template);
|
||||||
|
// the first one receives .tray-role-card and data-role instead of a new element
|
||||||
|
// being inserted, so the cell count never changes.
|
||||||
|
function placeCard(roleCode, onComplete) {
|
||||||
|
if (!_grid) { if (onComplete) onComplete(); return; }
|
||||||
|
var firstCell = _grid.querySelector('.tray-cell');
|
||||||
|
if (!firstCell) { if (onComplete) onComplete(); return; }
|
||||||
|
|
||||||
|
firstCell.classList.add('tray-role-card');
|
||||||
|
firstCell.dataset.role = roleCode;
|
||||||
|
firstCell.textContent = '';
|
||||||
|
if (_roleIconsUrl) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = _roleIconsUrl + 'starter-role-' + (_ROLE_SCRAWL[roleCode] || 'Blank') + '.svg';
|
||||||
|
img.alt = roleCode;
|
||||||
|
firstCell.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
open();
|
||||||
|
_arcIn(firstCell, function () {
|
||||||
|
close();
|
||||||
|
if (!onComplete) return;
|
||||||
|
if (!_wrap) { onComplete(); return; }
|
||||||
|
var propName = _isLandscape() ? 'top' : 'left';
|
||||||
|
var done = false;
|
||||||
|
function finish() {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (_wrap) _wrap.removeEventListener('transitionend', onCloseEnd);
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
function onCloseEnd(e) {
|
||||||
|
if (e.propertyName === propName) finish();
|
||||||
|
}
|
||||||
|
_wrap.addEventListener('transitionend', onCloseEnd);
|
||||||
|
setTimeout(finish, _closeTransitionMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startDrag(clientX, clientY) {
|
||||||
|
_dragHandled = false;
|
||||||
|
if (_wrap) _wrap.classList.add('tray-dragging');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
_dragStartY = clientY;
|
||||||
|
_dragStartX = null;
|
||||||
|
_dragStartTop = _wrap ? (parseInt(_wrap.style.top, 10) || _maxTop) : _maxTop;
|
||||||
|
_dragStartLeft = null;
|
||||||
|
} else {
|
||||||
|
_dragStartX = clientX;
|
||||||
|
_dragStartY = null;
|
||||||
|
_dragStartLeft = _wrap ? (parseInt(_wrap.style.left, 10) || _maxLeft) : _maxLeft;
|
||||||
|
_dragStartTop = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force-close and reposition to settled bounds. Called on both 'resize'
|
||||||
|
// (snap without transition to avoid flicker during continuous events) and
|
||||||
|
// 'resize:end' (re-measures after the viewport has stopped moving).
|
||||||
|
function _reposition() {
|
||||||
|
_cancelPendingHide();
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) _wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||||
|
|
||||||
|
if (_isLandscape()) {
|
||||||
|
// Ensure tray is visible before measuring bounds.
|
||||||
|
if (_tray) _tray.style.display = 'grid';
|
||||||
|
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; _wrap.style.width = ''; }
|
||||||
|
_computeBounds();
|
||||||
|
_computeCellSize();
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.top = _maxTop + 'px';
|
||||||
|
void _wrap.offsetWidth; // flush reflow so position lands before transition restored
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_tray) _tray.style.display = 'none';
|
||||||
|
if (_wrap) { _wrap.style.top = ''; _wrap.style.height = ''; _wrap.style.width = ''; }
|
||||||
|
_computeBounds();
|
||||||
|
_applyVerticalBounds();
|
||||||
|
_computeCellSize();
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.left = _maxLeft + 'px';
|
||||||
|
void _wrap.offsetWidth; // flush reflow
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
_wrap = document.getElementById('id_tray_wrap');
|
||||||
|
_btn = document.getElementById('id_tray_btn');
|
||||||
|
_tray = document.getElementById('id_tray');
|
||||||
|
_grid = document.getElementById('id_tray_grid');
|
||||||
|
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
|
||||||
|
if (!_btn) return;
|
||||||
|
|
||||||
|
if (_isLandscape()) {
|
||||||
|
// Show tray before measuring so offsetHeight includes it.
|
||||||
|
if (_tray) _tray.style.display = 'grid';
|
||||||
|
_computeBounds();
|
||||||
|
// Clear portrait's inline left/bottom so media-query CSS applies.
|
||||||
|
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; }
|
||||||
|
if (_wrap) _wrap.style.top = _maxTop + 'px';
|
||||||
|
_computeCellSize();
|
||||||
|
} else {
|
||||||
|
// Clear landscape's inline top/height/width so portrait CSS applies.
|
||||||
|
if (_wrap) { _wrap.style.top = ''; _wrap.style.width = ''; }
|
||||||
|
_applyVerticalBounds();
|
||||||
|
_computeCellSize(); // wrap has correct height after _applyVerticalBounds
|
||||||
|
_computeBounds();
|
||||||
|
if (_wrap) _wrap.style.left = _maxLeft + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag start — pointer and mouse variants so Selenium W3C actions
|
||||||
|
// and synthetic Jasmine PointerEvents both work.
|
||||||
|
_btn.addEventListener('pointerdown', function (e) {
|
||||||
|
_startDrag(e.clientX, e.clientY);
|
||||||
|
try { _btn.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
});
|
||||||
|
_btn.addEventListener('mousedown', function (e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (_dragStartX !== null || _dragStartY !== null) return;
|
||||||
|
_startDrag(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag move / end — on document so events that land elsewhere during
|
||||||
|
// the drag (no capture, or Selenium pointer quirks) still bubble here.
|
||||||
|
_onDocMove = function (e) {
|
||||||
|
if (!_wrap) return;
|
||||||
|
if (_isLandscape()) {
|
||||||
|
if (_dragStartY === null) return;
|
||||||
|
var newTop = _dragStartTop + (e.clientY - _dragStartY);
|
||||||
|
newTop = Math.max(_maxTop, Math.min(_minTop, newTop));
|
||||||
|
_wrap.style.top = newTop + 'px';
|
||||||
|
// Open when dragged below closed position; update state + class only.
|
||||||
|
// Tray display is not toggled in landscape — position controls visibility.
|
||||||
|
if (newTop > _maxTop) {
|
||||||
|
if (!_open) {
|
||||||
|
_open = true;
|
||||||
|
if (_btn) _btn.classList.add('open');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_open) {
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_dragStartX === null) return;
|
||||||
|
var newLeft = _dragStartLeft + (e.clientX - _dragStartX);
|
||||||
|
newLeft = Math.max(_minLeft, Math.min(_maxLeft, newLeft));
|
||||||
|
_wrap.style.left = newLeft + 'px';
|
||||||
|
if (newLeft < _maxLeft) {
|
||||||
|
if (!_open) {
|
||||||
|
_open = true;
|
||||||
|
if (_tray) _tray.style.display = 'grid';
|
||||||
|
if (_btn) _btn.classList.add('open');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_open) {
|
||||||
|
_open = false;
|
||||||
|
if (_tray) _tray.style.display = 'none';
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointermove', _onDocMove);
|
||||||
|
document.addEventListener('mousemove', _onDocMove);
|
||||||
|
|
||||||
|
_onDocUp = function (e) {
|
||||||
|
if (_isLandscape()) {
|
||||||
|
if (_dragStartY !== null && Math.abs(e.clientY - _dragStartY) > 10) {
|
||||||
|
_dragHandled = true;
|
||||||
|
}
|
||||||
|
_dragStartY = null;
|
||||||
|
_dragStartTop = null;
|
||||||
|
} else {
|
||||||
|
if (_dragStartX !== null && Math.abs(e.clientX - _dragStartX) > 10) {
|
||||||
|
_dragHandled = true;
|
||||||
|
}
|
||||||
|
_dragStartX = null;
|
||||||
|
_dragStartLeft = null;
|
||||||
|
}
|
||||||
|
if (_wrap) _wrap.classList.remove('tray-dragging');
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerup', _onDocUp);
|
||||||
|
document.addEventListener('mouseup', _onDocUp);
|
||||||
|
|
||||||
|
_onBtnClick = function () {
|
||||||
|
if (_dragHandled) {
|
||||||
|
_dragHandled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_open) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
_wobble();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_btn.addEventListener('click', _onBtnClick);
|
||||||
|
|
||||||
|
window.addEventListener('resize', _reposition);
|
||||||
|
window.addEventListener('resize:end', _reposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset() — restores module state; used by Jasmine afterEach
|
||||||
|
function reset() {
|
||||||
|
_open = false;
|
||||||
|
_closeTransitionMs = 0;
|
||||||
|
_dragStartX = null;
|
||||||
|
_dragStartY = null;
|
||||||
|
_dragStartLeft = null;
|
||||||
|
_dragStartTop = null;
|
||||||
|
_dragHandled = false;
|
||||||
|
_landscapeOverride = null;
|
||||||
|
// Restore portrait default (display:none); landscape init() will show it.
|
||||||
|
if (_tray) {
|
||||||
|
_tray.style.display = 'none';
|
||||||
|
_tray.style.removeProperty('--tray-cell-size');
|
||||||
|
}
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||||
|
_wrap.style.left = '';
|
||||||
|
_wrap.style.top = '';
|
||||||
|
_wrap.style.height = '';
|
||||||
|
_wrap.style.width = '';
|
||||||
|
}
|
||||||
|
if (_onDocMove) {
|
||||||
|
document.removeEventListener('pointermove', _onDocMove);
|
||||||
|
document.removeEventListener('mousemove', _onDocMove);
|
||||||
|
_onDocMove = null;
|
||||||
|
}
|
||||||
|
if (_onDocUp) {
|
||||||
|
document.removeEventListener('pointerup', _onDocUp);
|
||||||
|
document.removeEventListener('mouseup', _onDocUp);
|
||||||
|
_onDocUp = null;
|
||||||
|
}
|
||||||
|
if (_onBtnClick && _btn) {
|
||||||
|
_btn.removeEventListener('click', _onBtnClick);
|
||||||
|
_onBtnClick = null;
|
||||||
|
}
|
||||||
|
_cancelPendingHide();
|
||||||
|
// Clear any role-card state from tray cells (Jasmine afterEach)
|
||||||
|
if (_grid) {
|
||||||
|
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
|
||||||
|
el.classList.remove('tray-role-card', 'arc-in');
|
||||||
|
el.textContent = '';
|
||||||
|
delete el.dataset.role;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_wrap = null;
|
||||||
|
_btn = null;
|
||||||
|
_tray = null;
|
||||||
|
_grid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
open: open,
|
||||||
|
close: close,
|
||||||
|
forceClose: forceClose,
|
||||||
|
isOpen: isOpen,
|
||||||
|
placeCard: placeCard,
|
||||||
|
reset: reset,
|
||||||
|
_testSetLandscape: function (v) { _landscapeOverride = v; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
95
src/apps/epic/tasks.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Countdown scheduler for the polarity-room TAKE SIG gate.
|
||||||
|
|
||||||
|
Uses threading.Timer so no separate Celery worker is needed in development.
|
||||||
|
Single-process only — swap for a Celery task if production uses multiple
|
||||||
|
web workers (gunicorn -w N with N > 1).
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||||
|
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||||
|
|
||||||
|
# In-process registry of pending timers: "{room_id}_{polarity}" → Timer
|
||||||
|
_timers = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(room_id, polarity):
|
||||||
|
return f'sig_countdown_{room_id}_{polarity}'
|
||||||
|
|
||||||
|
|
||||||
|
def _group_send(room_id, msg):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(f'room_{room_id}', msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _fire(room_id, polarity, token):
|
||||||
|
"""Callback run by threading.Timer after the countdown expires."""
|
||||||
|
# Token guard: if cancelled or superseded, cache entry will differ
|
||||||
|
if cache.get(_cache_key(room_id, polarity)) != token:
|
||||||
|
return
|
||||||
|
|
||||||
|
from apps.epic.models import Room, SigReservation
|
||||||
|
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return
|
||||||
|
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
|
||||||
|
# Idempotency: seats already assigned
|
||||||
|
if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Safety: all three must still be ready
|
||||||
|
ready_reservations = list(
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
||||||
|
.select_related('seat', 'card')
|
||||||
|
)
|
||||||
|
if len(ready_reservations) < 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
for res in ready_reservations:
|
||||||
|
if res.seat:
|
||||||
|
res.seat.significator = res.card
|
||||||
|
res.seat.save(update_fields=['significator'])
|
||||||
|
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)
|
||||||
|
|
||||||
|
_group_send(room_id, {'type': 'polarity_room_done', 'polarity': polarity})
|
||||||
|
|
||||||
|
if not room.table_seats.filter(significator__isnull=True).exists():
|
||||||
|
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
|
||||||
|
_group_send(room_id, {'type': 'pick_sky_available'})
|
||||||
|
|
||||||
|
cache.delete(_cache_key(room_id, polarity))
|
||||||
|
_timers.pop(f'{room_id}_{polarity}', None)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_polarity_confirm(room_id, polarity, seconds):
|
||||||
|
"""Schedule a polarity confirm `seconds` seconds from now. Cancels any prior timer."""
|
||||||
|
cancel_polarity_confirm(room_id, polarity)
|
||||||
|
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
cache.set(_cache_key(room_id, polarity), token, timeout=int(seconds) + 60)
|
||||||
|
|
||||||
|
timer = threading.Timer(seconds, _fire, args=[str(room_id), polarity, token])
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
_timers[f'{room_id}_{polarity}'] = timer
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_polarity_confirm(room_id, polarity):
|
||||||
|
"""Cancel any pending confirm for this room + polarity."""
|
||||||
|
timer = _timers.pop(f'{room_id}_{polarity}', None)
|
||||||
|
if timer:
|
||||||
|
timer.cancel()
|
||||||
|
cache.delete(_cache_key(room_id, polarity))
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
from channels.db import database_sync_to_async
|
||||||
from channels.testing.websocket import WebsocketCommunicator
|
from channels.testing.websocket import WebsocketCommunicator
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.test import SimpleTestCase, override_settings
|
from django.test import Client, SimpleTestCase, TransactionTestCase, override_settings, tag
|
||||||
|
|
||||||
|
from apps.epic.models import Room, TableSeat
|
||||||
|
from apps.lyric.models import User
|
||||||
from core.asgi import application
|
from core.asgi import application
|
||||||
|
|
||||||
|
|
||||||
@@ -52,19 +55,33 @@ class RoomConsumerTest(SimpleTestCase):
|
|||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
async def test_receives_roles_revealed_broadcast(self):
|
async def test_receives_all_roles_filled_broadcast(self):
|
||||||
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
await communicator.connect()
|
await communicator.connect()
|
||||||
|
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
await channel_layer.group_send(
|
await channel_layer.group_send(
|
||||||
"room_00000000-0000-0000-0000-000000000001",
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
{"type": "roles_revealed", "assignments": {"1": "PC", "2": "BC"}},
|
{"type": "all_roles_filled"},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await communicator.receive_json_from()
|
response = await communicator.receive_json_from()
|
||||||
self.assertEqual(response["type"], "roles_revealed")
|
self.assertEqual(response["type"], "all_roles_filled")
|
||||||
self.assertIn("assignments", response)
|
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
async def test_receives_sig_select_started_broadcast(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
await communicator.connect()
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
|
{"type": "sig_select_started"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response["type"], "sig_select_started")
|
||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
@@ -83,3 +100,162 @@ class RoomConsumerTest(SimpleTestCase):
|
|||||||
self.assertEqual(response["gate_state"], "some_state")
|
self.assertEqual(response["gate_state"], "some_state")
|
||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
@tag('channels')
|
||||||
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class CursorMoveConsumerTest(TransactionTestCase):
|
||||||
|
"""Cursor moves are broadcast only within the same polarity group
|
||||||
|
(levity: PC/NC/SC — gravity: BC/EC/AC)."""
|
||||||
|
|
||||||
|
async def _make_communicator(self, user, room):
|
||||||
|
client = Client()
|
||||||
|
await database_sync_to_async(client.force_login)(user)
|
||||||
|
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||||
|
comm = WebsocketCommunicator(
|
||||||
|
application,
|
||||||
|
f"/ws/room/{room.id}/",
|
||||||
|
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||||
|
)
|
||||||
|
connected, _ = await comm.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
return comm
|
||||||
|
|
||||||
|
async def test_levity_cursor_received_by_fellow_levity_player(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
nc_comm = await self._make_communicator(nc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({"type": "cursor_move", "x": 0.5, "y": 0.3})
|
||||||
|
|
||||||
|
msg = await nc_comm.receive_json_from(timeout=2)
|
||||||
|
self.assertEqual(msg["type"], "cursor_move")
|
||||||
|
self.assertAlmostEqual(msg["x"], 0.5)
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await nc_comm.disconnect()
|
||||||
|
|
||||||
|
async def test_levity_cursor_not_received_by_gravity_player(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=bc_user, slot_number=2, role="BC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
bc_comm = await self._make_communicator(bc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({"type": "cursor_move", "x": 0.5, "y": 0.3})
|
||||||
|
|
||||||
|
self.assertTrue(await bc_comm.receive_nothing(timeout=1))
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await bc_comm.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
@tag('channels')
|
||||||
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class SigHoverConsumerTest(TransactionTestCase):
|
||||||
|
"""sig_hover messages sent by a client are forwarded within the polarity group only."""
|
||||||
|
|
||||||
|
async def _make_communicator(self, user, room):
|
||||||
|
client = Client()
|
||||||
|
await database_sync_to_async(client.force_login)(user)
|
||||||
|
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||||
|
comm = WebsocketCommunicator(
|
||||||
|
application,
|
||||||
|
f"/ws/room/{room.id}/",
|
||||||
|
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||||
|
)
|
||||||
|
connected, _ = await comm.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
return comm
|
||||||
|
|
||||||
|
async def test_sig_hover_forwarded_to_polarity_group(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
nc_comm = await self._make_communicator(nc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({
|
||||||
|
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||||
|
})
|
||||||
|
|
||||||
|
msg = await nc_comm.receive_json_from(timeout=2)
|
||||||
|
self.assertEqual(msg["type"], "sig_hover")
|
||||||
|
self.assertEqual(msg["card_id"], "abc-123")
|
||||||
|
self.assertEqual(msg["role"], "PC")
|
||||||
|
self.assertTrue(msg["active"])
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await nc_comm.disconnect()
|
||||||
|
|
||||||
|
async def test_sig_hover_not_forwarded_to_other_polarity(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=bc_user, slot_number=2, role="BC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
bc_comm = await self._make_communicator(bc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({
|
||||||
|
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(await bc_comm.receive_nothing(timeout=1))
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await bc_comm.disconnect()
|
||||||
|
|
||||||
|
async def test_sig_reserved_broadcast_received_by_polarity_group(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||||
|
)
|
||||||
|
|
||||||
|
nc_comm = await self._make_communicator(nc_user, room)
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
f"cursors_{room.id}_levity",
|
||||||
|
{"type": "sig_reserved", "card_id": "card-xyz", "role": "PC", "reserved": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = await nc_comm.receive_json_from(timeout=2)
|
||||||
|
self.assertEqual(msg["type"], "sig_reserved")
|
||||||
|
self.assertEqual(msg["card_id"], "card-xyz")
|
||||||
|
self.assertTrue(msg["reserved"])
|
||||||
|
|
||||||
|
await nc_comm.disconnect()
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
from apps.lyric.models import Token, User
|
from apps.lyric.models import Token, User
|
||||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
|
from apps.epic.models import (
|
||||||
|
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||||
|
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
|
||||||
|
sig_seat_order, active_sig_seat,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoomCreationTest(TestCase):
|
class RoomCreationTest(TestCase):
|
||||||
@@ -214,3 +220,376 @@ class RoomInviteTest(TestCase):
|
|||||||
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
|
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
|
||||||
).distinct()
|
).distinct()
|
||||||
self.assertIn(self.room, rooms)
|
self.assertIn(self.room, rooms)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
|
def _full_sig_room(name="Sig Room", role_order=None):
|
||||||
|
"""Return (room, gamers, earthman) with all 6 seats filled, roles assigned,
|
||||||
|
table_status=SIG_SELECT, and every gamer's equipped_deck set to Earthman.
|
||||||
|
Uses get_or_create for DeckVariant — migration data persists in TestCase."""
|
||||||
|
if role_order is None:
|
||||||
|
role_order = SIG_SEAT_ORDER[:]
|
||||||
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
owner = User.objects.create(email="founder@sig.io")
|
||||||
|
gamers = [owner]
|
||||||
|
for i in range(2, 7):
|
||||||
|
gamers.append(User.objects.create(email=f"g{i}@sig.io"))
|
||||||
|
for gamer in gamers:
|
||||||
|
gamer.equipped_deck = earthman
|
||||||
|
gamer.save(update_fields=["equipped_deck"])
|
||||||
|
room = Room.objects.create(name=name, owner=owner)
|
||||||
|
for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
|
||||||
|
slot = room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=gamer, slot_number=i,
|
||||||
|
role=role, role_revealed=True,
|
||||||
|
)
|
||||||
|
room.table_status = Room.SIG_SELECT
|
||||||
|
room.save()
|
||||||
|
return room, gamers, earthman
|
||||||
|
|
||||||
|
|
||||||
|
class SigDeckCompositionTest(TestCase):
|
||||||
|
"""sig_deck_cards(room) returns exactly 36 cards with correct suit/arcana split."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman = _full_sig_room()
|
||||||
|
|
||||||
|
def test_sig_deck_returns_36_cards(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
self.assertEqual(len(cards), 36)
|
||||||
|
|
||||||
|
def test_sc_ac_contribute_court_cards_of_blades_and_grails(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
sc_ac = [c for c in cards if c.suit in ("BLADES", "GRAILS")]
|
||||||
|
# M/J/Q/K × 2 suits × 2 roles = 16
|
||||||
|
self.assertEqual(len(sc_ac), 16)
|
||||||
|
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
|
||||||
|
|
||||||
|
def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")]
|
||||||
|
self.assertEqual(len(pc_bc), 16)
|
||||||
|
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
|
||||||
|
|
||||||
|
def test_nc_ec_contribute_schiz_and_chancellor(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
major = [c for c in cards if c.arcana == "MAJOR"]
|
||||||
|
self.assertEqual(len(major), 4)
|
||||||
|
self.assertEqual(sorted(c.number for c in major), [0, 0, 1, 1])
|
||||||
|
|
||||||
|
def test_each_card_appears_twice_once_per_pile(self):
|
||||||
|
"""18 unique card specs × 2 (levity + gravity) = 36 total."""
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
slugs = [c.slug for c in cards]
|
||||||
|
unique_slugs = set(slugs)
|
||||||
|
self.assertEqual(len(unique_slugs), 18)
|
||||||
|
self.assertTrue(all(slugs.count(s) == 2 for s in unique_slugs))
|
||||||
|
|
||||||
|
|
||||||
|
class SigSeatOrderTest(TestCase):
|
||||||
|
"""sig_seat_order() and active_sig_seat() return seats in PC→NC→EC→SC→AC→BC order."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Assign roles in reverse of canonical order to prove reordering works
|
||||||
|
self.room, self.gamers, _ = _full_sig_room(
|
||||||
|
name="Order Room",
|
||||||
|
role_order=["BC", "AC", "SC", "EC", "NC", "PC"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sig_seat_order_returns_canonical_role_sequence(self):
|
||||||
|
seats = sig_seat_order(self.room)
|
||||||
|
self.assertEqual([s.role for s in seats], SIG_SEAT_ORDER)
|
||||||
|
|
||||||
|
def test_active_sig_seat_is_first_seat_without_significator(self):
|
||||||
|
seat = active_sig_seat(self.room)
|
||||||
|
self.assertEqual(seat.role, "PC")
|
||||||
|
|
||||||
|
def test_active_sig_seat_advances_after_significator_set(self):
|
||||||
|
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
card = TarotCard.objects.filter(deck_variant=earthman, arcana="MINOR").first()
|
||||||
|
pc_seat.significator = card
|
||||||
|
pc_seat.save()
|
||||||
|
seat = active_sig_seat(self.room)
|
||||||
|
self.assertEqual(seat.role, "NC")
|
||||||
|
|
||||||
|
def test_active_sig_seat_is_none_when_all_chosen(self):
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
cards = list(TarotCard.objects.filter(deck_variant=earthman))
|
||||||
|
for i, seat in enumerate(TableSeat.objects.filter(room=self.room)):
|
||||||
|
seat.significator = cards[i]
|
||||||
|
seat.save()
|
||||||
|
self.assertIsNone(active_sig_seat(self.room))
|
||||||
|
|
||||||
|
|
||||||
|
class SigCardFieldTest(TestCase):
|
||||||
|
"""TableSeat.significator FK to TarotCard — default null, assignable."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
self.card = TarotCard.objects.get(
|
||||||
|
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11,
|
||||||
|
)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
room = Room.objects.create(name="Field Test", owner=owner)
|
||||||
|
self.seat = TableSeat.objects.create(room=room, gamer=owner, slot_number=1, role="PC")
|
||||||
|
|
||||||
|
def test_significator_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.seat.significator)
|
||||||
|
|
||||||
|
def test_significator_can_be_assigned(self):
|
||||||
|
self.seat.significator = self.card
|
||||||
|
self.seat.save()
|
||||||
|
self.seat.refresh_from_db()
|
||||||
|
self.assertEqual(self.seat.significator, self.card)
|
||||||
|
|
||||||
|
def test_significator_nullable_on_delete(self):
|
||||||
|
self.seat.significator = self.card
|
||||||
|
self.seat.save()
|
||||||
|
self.card.delete()
|
||||||
|
self.seat.refresh_from_db()
|
||||||
|
self.assertIsNone(self.seat.significator)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigReservation model ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_sig_card(deck_variant, suit, number):
|
||||||
|
name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||||
|
card, _ = TarotCard.objects.get_or_create(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
|
||||||
|
defaults={
|
||||||
|
"arcana": "MINOR", "suit": suit, "number": number,
|
||||||
|
"name": f"{name_map[number]} of {suit.capitalize()}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
class SigReservationModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Sig Room", owner=self.owner)
|
||||||
|
self.card = _make_sig_card(self.earthman, "WANDS", 14)
|
||||||
|
self.seat = TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_can_create_sig_reservation(self):
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
self.assertEqual(res.role, "PC")
|
||||||
|
self.assertEqual(res.polarity, "levity")
|
||||||
|
self.assertIsNotNone(res.reserved_at)
|
||||||
|
|
||||||
|
def test_one_reservation_per_gamer_per_room(self):
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
card2 = _make_sig_card(self.earthman, "CUPS", 13)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_card_blocked_within_same_polarity(self):
|
||||||
|
gamer2 = User.objects.create(email="nc@test.io")
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_card_allowed_across_polarity(self):
|
||||||
|
"""A gravity gamer may reserve the same card instance as a levity gamer
|
||||||
|
— each polarity has its own independent pile."""
|
||||||
|
gamer2 = User.objects.create(email="bc@test.io")
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
res2 = SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(res2.pk)
|
||||||
|
|
||||||
|
def test_deleting_reservation_clears_slot(self):
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
res.delete()
|
||||||
|
self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class SigCardHelperTest(TestCase):
|
||||||
|
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each.
|
||||||
|
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Earthman deck is already seeded by migrations
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
|
self.owner.equipped_deck = self.earthman
|
||||||
|
self.owner.save()
|
||||||
|
self.room = Room.objects.create(name="Card Test", owner=self.owner)
|
||||||
|
|
||||||
|
def test_levity_sig_cards_returns_18(self):
|
||||||
|
cards = levity_sig_cards(self.room)
|
||||||
|
self.assertEqual(len(cards), 18)
|
||||||
|
|
||||||
|
def test_gravity_sig_cards_returns_18(self):
|
||||||
|
cards = gravity_sig_cards(self.room)
|
||||||
|
self.assertEqual(len(cards), 18)
|
||||||
|
|
||||||
|
def test_levity_and_gravity_share_same_card_objects(self):
|
||||||
|
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
||||||
|
comes from CSS polarity class, not separate card model records."""
|
||||||
|
levity = levity_sig_cards(self.room)
|
||||||
|
gravity = gravity_sig_cards(self.room)
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(c.pk for c in levity),
|
||||||
|
sorted(c.pk for c in gravity),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_equipped_deck(self):
|
||||||
|
self.owner.equipped_deck = None
|
||||||
|
self.owner.save()
|
||||||
|
self.assertEqual(levity_sig_cards(self.room), [])
|
||||||
|
self.assertEqual(gravity_sig_cards(self.room), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TarotCardCautionsTest(TestCase):
|
||||||
|
"""TarotCard.cautions JSONField — field existence and Schizo seed data."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
|
||||||
|
def test_cautions_field_saves_and_retrieves_list(self):
|
||||||
|
card = TarotCard.objects.create(
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
arcana="MINOR",
|
||||||
|
suit="CROWNS",
|
||||||
|
number=99,
|
||||||
|
name="Test Card",
|
||||||
|
slug="test-card-cautions",
|
||||||
|
cautions=["First caution.", "Second caution."],
|
||||||
|
)
|
||||||
|
card.refresh_from_db()
|
||||||
|
self.assertEqual(card.cautions, ["First caution.", "Second caution."])
|
||||||
|
|
||||||
|
def test_cautions_defaults_to_empty_list(self):
|
||||||
|
card = TarotCard.objects.create(
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
arcana="MINOR",
|
||||||
|
suit="CROWNS",
|
||||||
|
number=98,
|
||||||
|
name="Default Cautions Card",
|
||||||
|
slug="default-cautions-card",
|
||||||
|
)
|
||||||
|
self.assertEqual(card.cautions, [])
|
||||||
|
|
||||||
|
def test_schizo_has_4_cautions(self):
|
||||||
|
schizo = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||||
|
)
|
||||||
|
self.assertEqual(len(schizo.cautions), 4)
|
||||||
|
|
||||||
|
def test_schizo_caution_references_the_pervert(self):
|
||||||
|
schizo = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||||
|
)
|
||||||
|
self.assertIn("The Pervert", schizo.cautions[0])
|
||||||
|
|
||||||
|
def test_schizo_cautions_use_reverse_language(self):
|
||||||
|
schizo = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||||
|
)
|
||||||
|
for caution in schizo.cautions:
|
||||||
|
self.assertIn("reverse", caution)
|
||||||
|
self.assertNotIn("transform", caution)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigReservation ready gate ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SigReservationReadyGateTest(TestCase):
|
||||||
|
"""SigReservation.ready and countdown_remaining fields."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
room = Room.objects.create(name="R", owner=owner)
|
||||||
|
card = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||||
|
)
|
||||||
|
self.res = SigReservation.objects.create(
|
||||||
|
room=room, gamer=owner, card=card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ready_defaults_to_false(self):
|
||||||
|
self.assertFalse(self.res.ready)
|
||||||
|
|
||||||
|
def test_countdown_remaining_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.res.countdown_remaining)
|
||||||
|
|
||||||
|
def test_ready_can_be_set_true(self):
|
||||||
|
self.res.ready = True
|
||||||
|
self.res.save()
|
||||||
|
self.res.refresh_from_db()
|
||||||
|
self.assertTrue(self.res.ready)
|
||||||
|
|
||||||
|
def test_countdown_remaining_can_be_saved(self):
|
||||||
|
self.res.countdown_remaining = 7
|
||||||
|
self.res.save()
|
||||||
|
self.res.refresh_from_db()
|
||||||
|
self.assertEqual(self.res.countdown_remaining, 7)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Room SKY_SELECT status ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RoomSkySelectStatusTest(TestCase):
|
||||||
|
"""Room.SKY_SELECT constant and sig_select_started_at field."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="R", owner=owner)
|
||||||
|
|
||||||
|
def test_sky_select_constant_value(self):
|
||||||
|
self.assertEqual(Room.SKY_SELECT, "SKY_SELECT")
|
||||||
|
|
||||||
|
def test_sky_select_is_valid_table_status_choice(self):
|
||||||
|
choices = [c[0] for c in Room.TABLE_STATUS_CHOICES]
|
||||||
|
self.assertIn(Room.SKY_SELECT, choices)
|
||||||
|
|
||||||
|
def test_sig_select_started_at_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.room.sig_select_started_at)
|
||||||
|
|
||||||
|
def test_sig_select_started_at_can_be_set(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
now = timezone.now()
|
||||||
|
self.room.sig_select_started_at = now
|
||||||
|
self.room.save()
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertIsNotNone(self.room.sig_select_started_at)
|
||||||
|
|||||||
@@ -6,15 +6,23 @@ app_name = 'epic'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('rooms/create_room', views.create_room, name='create_room'),
|
path('rooms/create_room', views.create_room, name='create_room'),
|
||||||
|
path('room/<uuid:room_id>/', views.room_view, name='room'),
|
||||||
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
||||||
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
|
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
|
||||||
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
|
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
|
||||||
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
||||||
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
|
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
|
||||||
path('room/<uuid:room_id>/pick-roles', views.pick_roles, name='pick_roles'),
|
path('room/<uuid:room_id>/pick-roles', views.pick_roles, name='pick_roles'),
|
||||||
|
path('room/<uuid:room_id>/pick-sigs', views.pick_sigs, name='pick_sigs'),
|
||||||
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
||||||
|
path('room/<uuid:room_id>/select-sig', views.select_sig, name='select_sig'),
|
||||||
|
path('room/<uuid:room_id>/sig-reserve', views.sig_reserve, name='sig_reserve'),
|
||||||
|
path('room/<uuid:room_id>/sig-ready', views.sig_ready, name='sig_ready'),
|
||||||
|
path('room/<uuid:room_id>/sig-confirm', views.sig_confirm, name='sig_confirm'),
|
||||||
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
||||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||||
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
|
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
|
||||||
|
path('room/<uuid:room_id>/tarot/', views.tarot_deck, name='tarot_deck'),
|
||||||
|
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
|
from apps.drama.models import GameEvent, record
|
||||||
|
from django.db.models import Case, IntegerField, Value, When
|
||||||
|
|
||||||
|
from apps.epic.models import (
|
||||||
|
GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
|
||||||
|
TarotCard, TarotDeck,
|
||||||
|
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
|
||||||
|
select_token, sig_deck_cards,
|
||||||
|
)
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
|
|
||||||
@@ -26,20 +36,27 @@ def _notify_turn_changed(room_id):
|
|||||||
room_id=room_id, role__isnull=True
|
room_id=room_id, role__isnull=True
|
||||||
).order_by("slot_number").first()
|
).order_by("slot_number").first()
|
||||||
active_slot = active_seat.slot_number if active_seat else None
|
active_slot = active_seat.slot_number if active_seat else None
|
||||||
|
starter_roles = list(
|
||||||
|
TableSeat.objects.filter(room_id=room_id, role__isnull=False)
|
||||||
|
.values_list("role", flat=True)
|
||||||
|
)
|
||||||
async_to_sync(get_channel_layer().group_send)(
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
f'room_{room_id}',
|
f'room_{room_id}',
|
||||||
{'type': 'turn_changed', 'active_slot': active_slot},
|
{'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_roles_revealed(room_id):
|
def _notify_all_roles_filled(room_id):
|
||||||
assignments = {
|
|
||||||
str(seat.slot_number): seat.role
|
|
||||||
for seat in TableSeat.objects.filter(room_id=room_id).order_by("slot_number")
|
|
||||||
}
|
|
||||||
async_to_sync(get_channel_layer().group_send)(
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
f'room_{room_id}',
|
f'room_{room_id}',
|
||||||
{'type': 'roles_revealed', 'assignments': assignments},
|
{'type': 'all_roles_filled'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_sig_select_started(room_id):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'sig_select_started'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +72,94 @@ def _notify_role_select_start(room_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||||
|
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_sig_reserved(room_id, card_id, role, reserved):
|
||||||
|
"""Broadcast a sig_reserved event to the matching polarity cursor group."""
|
||||||
|
polarity = 'levity' if role in _LEVITY_ROLES else 'gravity'
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None,
|
||||||
|
'role': role, 'reserved': reserved},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_countdown_start(room_id, polarity, *, seconds):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'countdown_start', 'polarity': polarity, 'seconds': seconds},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_countdown_cancel(room_id, polarity, *, seconds_remaining):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'countdown_cancel', 'polarity': polarity, 'seconds_remaining': seconds_remaining},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_polarity_room_done(room_id, polarity):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'polarity_room_done', 'polarity': polarity},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_pick_sky_available(room_id):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'pick_sky_available'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||||
|
|
||||||
|
_SIG_SEAT_ORDERING = Case(
|
||||||
|
*[When(role=r, then=Value(i)) for i, r in enumerate(SIG_SEAT_ORDER)],
|
||||||
|
default=Value(99),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_user_seat(room, user):
|
||||||
|
"""Return the user's seat whose role comes first in PC→NC→EC→SC→AC→BC order.
|
||||||
|
|
||||||
|
In normal play (one user = one seat) this is equivalent to .first().
|
||||||
|
For Carte Blanche (one user = all seats) it returns the PC seat, ensuring
|
||||||
|
sig-select cursor placement is seat-based, not position/slot-based.
|
||||||
|
"""
|
||||||
|
return room.table_seats.filter(gamer=user).order_by(_SIG_SEAT_ORDERING).first()
|
||||||
|
|
||||||
|
_ROLE_SCRAWL_NAMES = {
|
||||||
|
"PC": "Player", "NC": "Narrator", "EC": "Economist",
|
||||||
|
"SC": "Shepherd", "AC": "Alchemist", "BC": "Builder",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gate_positions(room):
|
||||||
|
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
|
||||||
|
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
|
||||||
|
# of which role each gamer chose — so use count, not role matching.
|
||||||
|
assigned_count = room.table_seats.exclude(role__isnull=True).count()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slot": slot,
|
||||||
|
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
|
||||||
|
"role_assigned": slot.slot_number <= assigned_count,
|
||||||
|
}
|
||||||
|
for slot in room.gate_slots.order_by("slot_number")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _expire_reserved_slots(room):
|
def _expire_reserved_slots(room):
|
||||||
cutoff = timezone.now() - RESERVE_TIMEOUT
|
cutoff = timezone.now() - RESERVE_TIMEOUT
|
||||||
room.gate_slots.filter(
|
room.gate_slots.filter(
|
||||||
@@ -119,6 +224,8 @@ def _gate_context(room, user):
|
|||||||
"carte_slots_claimed": carte_slots_claimed,
|
"carte_slots_claimed": carte_slots_claimed,
|
||||||
"carte_nvm_slot_number": carte_nvm_slot_number,
|
"carte_nvm_slot_number": carte_nvm_slot_number,
|
||||||
"carte_next_slot_number": carte_next_slot_number,
|
"carte_next_slot_number": carte_next_slot_number,
|
||||||
|
"gate_positions": _gate_positions(room),
|
||||||
|
"starter_roles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,9 +254,11 @@ def _role_select_context(room, user):
|
|||||||
card_stack_state = "eligible"
|
card_stack_state = "eligible"
|
||||||
else:
|
else:
|
||||||
card_stack_state = "ineligible"
|
card_stack_state = "ineligible"
|
||||||
taken_roles = list(
|
starter_roles = list(
|
||||||
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
|
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
|
||||||
)
|
)
|
||||||
|
if len(starter_roles) == 6:
|
||||||
|
card_stack_state = None
|
||||||
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
|
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
|
||||||
assigned_seats = (
|
assigned_seats = (
|
||||||
sorted(
|
sorted(
|
||||||
@@ -159,10 +268,16 @@ def _role_select_context(room, user):
|
|||||||
if user.is_authenticated else []
|
if user.is_authenticated else []
|
||||||
)
|
)
|
||||||
active_slot = active_seat.slot_number if active_seat else None
|
active_slot = active_seat.slot_number if active_seat else None
|
||||||
|
_my_role = assigned_seats[0].role if assigned_seats else None
|
||||||
ctx = {
|
ctx = {
|
||||||
"card_stack_state": card_stack_state,
|
"card_stack_state": card_stack_state,
|
||||||
"taken_roles": taken_roles,
|
"starter_roles": starter_roles,
|
||||||
"assigned_seats": assigned_seats,
|
"assigned_seats": assigned_seats,
|
||||||
|
"my_tray_role": _my_role,
|
||||||
|
"my_tray_scrawl_static_path": (
|
||||||
|
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
|
||||||
|
if _my_role else None
|
||||||
|
),
|
||||||
"user_seat": user_seat,
|
"user_seat": user_seat,
|
||||||
"user_slots": list(
|
"user_slots": list(
|
||||||
room.table_seats.filter(gamer=user, role__isnull=True)
|
room.table_seats.filter(gamer=user, role__isnull=True)
|
||||||
@@ -170,14 +285,58 @@ def _role_select_context(room, user):
|
|||||||
.values_list("slot_number", flat=True)
|
.values_list("slot_number", flat=True)
|
||||||
) if user.is_authenticated else [],
|
) if user.is_authenticated else [],
|
||||||
"active_slot": active_slot,
|
"active_slot": active_slot,
|
||||||
|
"gate_positions": _gate_positions(room),
|
||||||
|
"slots": room.gate_slots.order_by("slot_number"),
|
||||||
}
|
}
|
||||||
|
# Tray cell 2: sig card (set once polarity group confirms)
|
||||||
|
_canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||||
|
ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None
|
||||||
|
|
||||||
if room.table_status == Room.SIG_SELECT:
|
if room.table_status == Room.SIG_SELECT:
|
||||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||||
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
|
user_role = user_seat.role if user_seat else None
|
||||||
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
|
user_polarity = None
|
||||||
|
if user_role in _LEVITY_ROLES:
|
||||||
|
user_polarity = 'levity'
|
||||||
|
elif user_role in _GRAVITY_ROLES:
|
||||||
|
user_polarity = 'gravity'
|
||||||
|
|
||||||
|
user_reservation = SigReservation.objects.filter(
|
||||||
|
room=room, gamer=user
|
||||||
|
).first() if user.is_authenticated else None
|
||||||
ctx["user_seat"] = user_seat
|
ctx["user_seat"] = user_seat
|
||||||
ctx["partner_seat"] = partner_seat
|
ctx["user_polarity"] = user_polarity
|
||||||
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
|
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
|
||||||
|
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
||||||
|
|
||||||
|
# Has this gamer's polarity already had significators assigned?
|
||||||
|
# (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
|
||||||
|
if user_polarity:
|
||||||
|
_polarity_roles = _LEVITY_ROLES if user_polarity == 'levity' else _GRAVITY_ROLES
|
||||||
|
ctx["polarity_done"] = not room.table_seats.filter(
|
||||||
|
role__in=_polarity_roles, significator__isnull=True
|
||||||
|
).exists()
|
||||||
|
else:
|
||||||
|
ctx["polarity_done"] = False
|
||||||
|
|
||||||
|
# Pre-load existing reservations for this polarity so JS can restore
|
||||||
|
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
|
||||||
|
if user_polarity:
|
||||||
|
polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY
|
||||||
|
reservations = {
|
||||||
|
str(res.card_id): res.role
|
||||||
|
for res in room.sig_reservations.filter(polarity=polarity_const)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
reservations = {}
|
||||||
|
ctx["sig_reservations_json"] = json.dumps(reservations)
|
||||||
|
|
||||||
|
if user_polarity == 'levity':
|
||||||
|
ctx["sig_cards"] = levity_sig_cards(room)
|
||||||
|
elif user_polarity == 'gravity':
|
||||||
|
ctx["sig_cards"] = gravity_sig_cards(room)
|
||||||
|
else:
|
||||||
|
ctx["sig_cards"] = []
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -194,10 +353,18 @@ def create_room(request):
|
|||||||
def gatekeeper(request, room_id):
|
def gatekeeper(request, room_id):
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.table_status:
|
if room.table_status:
|
||||||
ctx = _role_select_context(room, request.user)
|
return redirect("epic:room", room_id=room_id)
|
||||||
else:
|
ctx = _gate_context(room, request.user)
|
||||||
ctx = _gate_context(room, request.user)
|
|
||||||
ctx["room"] = room
|
ctx["room"] = room
|
||||||
|
ctx["page_class"] = "page-gameboard"
|
||||||
|
return render(request, "apps/gameboard/room.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def room_view(request, room_id):
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
ctx = _role_select_context(room, request.user)
|
||||||
|
ctx["room"] = room
|
||||||
|
ctx["page_class"] = "page-gameboard"
|
||||||
return render(request, "apps/gameboard/room.html", ctx)
|
return render(request, "apps/gameboard/room.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -257,6 +424,10 @@ def confirm_token(request, room_id):
|
|||||||
if int(slot_number) > carte.slots_claimed:
|
if int(slot_number) > carte.slots_claimed:
|
||||||
carte.slots_claimed = int(slot_number)
|
carte.slots_claimed = int(slot_number)
|
||||||
carte.save()
|
carte.save()
|
||||||
|
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||||
|
slot_number=int(slot_number), token_type=Token.CARTE,
|
||||||
|
token_display=carte.get_token_type_display(),
|
||||||
|
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
||||||
_notify_gate_update(room_id)
|
_notify_gate_update(room_id)
|
||||||
else:
|
else:
|
||||||
slot = room.gate_slots.filter(
|
slot = room.gate_slots.filter(
|
||||||
@@ -271,6 +442,10 @@ def confirm_token(request, room_id):
|
|||||||
token = select_token(request.user)
|
token = select_token(request.user)
|
||||||
if token:
|
if token:
|
||||||
debit_token(request.user, slot, token)
|
debit_token(request.user, slot, token)
|
||||||
|
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||||
|
slot_number=slot.slot_number, token_type=token.token_type,
|
||||||
|
token_display=token.get_token_type_display(),
|
||||||
|
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
||||||
_notify_gate_update(room_id)
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
|
||||||
@@ -360,34 +535,51 @@ def select_role(request, room_id):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.table_status != Room.ROLE_SELECT:
|
if room.table_status != Room.ROLE_SELECT:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect(
|
||||||
active_seat = room.table_seats.filter(
|
"epic:room" if room.table_status else "epic:gatekeeper",
|
||||||
role__isnull=True
|
room_id=room_id,
|
||||||
).order_by("slot_number").first()
|
)
|
||||||
if not active_seat or active_seat.gamer != request.user:
|
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
|
||||||
role = request.POST.get("role")
|
role = request.POST.get("role")
|
||||||
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
||||||
if not role or role not in valid_roles:
|
if not role or role not in valid_roles:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
if room.table_seats.filter(role=role).exists():
|
with transaction.atomic():
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
active_seat = room.table_seats.select_for_update().filter(
|
||||||
active_seat.role = role
|
role__isnull=True
|
||||||
active_seat.save()
|
).order_by("slot_number").first()
|
||||||
|
if not active_seat or active_seat.gamer != request.user:
|
||||||
|
return redirect("epic:room", room_id=room_id)
|
||||||
|
if room.table_seats.filter(role=role).exists():
|
||||||
|
return HttpResponse(status=409)
|
||||||
|
active_seat.role = role
|
||||||
|
active_seat.save()
|
||||||
|
record(room, GameEvent.ROLE_SELECTED, actor=request.user,
|
||||||
|
role=role, slot_number=active_seat.slot_number,
|
||||||
|
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
|
||||||
if room.table_seats.filter(role__isnull=True).exists():
|
if room.table_seats.filter(role__isnull=True).exists():
|
||||||
_notify_turn_changed(room_id)
|
_notify_turn_changed(room_id)
|
||||||
else:
|
else:
|
||||||
|
_notify_all_roles_filled(room_id)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
return redirect("epic:room", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def pick_sigs(request, room_id):
|
||||||
|
if request.method == "POST":
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status == Room.ROLE_SELECT:
|
||||||
room.table_status = Room.SIG_SELECT
|
room.table_status = Room.SIG_SELECT
|
||||||
room.save()
|
room.save()
|
||||||
_notify_roles_revealed(room_id)
|
_notify_sig_select_started(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def pick_roles(request, room_id):
|
def pick_roles(request, room_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.gate_status == Room.OPEN:
|
if room.gate_status == Room.OPEN and room.table_status is None:
|
||||||
room.table_status = Room.ROLE_SELECT
|
room.table_status = Room.ROLE_SELECT
|
||||||
room.save()
|
room.save()
|
||||||
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
|
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
|
||||||
@@ -397,7 +589,7 @@ def pick_roles(request, room_id):
|
|||||||
slot_number=slot.slot_number,
|
slot_number=slot.slot_number,
|
||||||
)
|
)
|
||||||
_notify_role_select_start(room_id)
|
_notify_role_select_start(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -443,3 +635,243 @@ def gate_status(request, room_id):
|
|||||||
ctx = _gate_context(room, request.user)
|
ctx = _gate_context(room, request.user)
|
||||||
ctx["room"] = room
|
ctx["room"] = room
|
||||||
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sig_reserve(request, room_id):
|
||||||
|
"""Provisional card hold (OK / NVM) during SIG_SELECT.
|
||||||
|
POST body: card_id=<uuid>, action=reserve|release
|
||||||
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
user_seat = _canonical_user_seat(room, request.user)
|
||||||
|
if not user_seat or not user_seat.role:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
action = request.POST.get("action", "reserve")
|
||||||
|
|
||||||
|
if action == "release":
|
||||||
|
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
|
released_card_id = existing.card_id if existing else None
|
||||||
|
if existing and existing.ready:
|
||||||
|
# Gamer released while ready — treat as an implicit WAIT NVM
|
||||||
|
prior = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_READY
|
||||||
|
).last()
|
||||||
|
if prior and not prior.data.get("retracted"):
|
||||||
|
prior.data["retracted"] = True
|
||||||
|
prior.save(update_fields=["data"])
|
||||||
|
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||||
|
polarity = existing.polarity
|
||||||
|
all_ready = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity, ready=True
|
||||||
|
).count() == 3
|
||||||
|
if all_ready:
|
||||||
|
_notify_countdown_cancel(room_id, polarity, seconds_remaining=12)
|
||||||
|
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
||||||
|
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
# Reserve action
|
||||||
|
card_id = request.POST.get("card_id")
|
||||||
|
try:
|
||||||
|
card = TarotCard.objects.get(pk=card_id)
|
||||||
|
except TarotCard.DoesNotExist:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
|
||||||
|
|
||||||
|
# Block if another gamer in the same polarity already holds this card
|
||||||
|
if SigReservation.objects.filter(
|
||||||
|
room=room, card=card, polarity=polarity
|
||||||
|
).exclude(gamer=request.user).exists():
|
||||||
|
return HttpResponse(status=409)
|
||||||
|
|
||||||
|
# Block if this gamer already holds a *different* card — must NVM first
|
||||||
|
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
|
if existing and existing.card != card:
|
||||||
|
return HttpResponse(status=409)
|
||||||
|
|
||||||
|
# Idempotent: already holding the same card
|
||||||
|
if existing:
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=room, gamer=request.user, card=card,
|
||||||
|
seat=user_seat, role=user_seat.role, polarity=polarity,
|
||||||
|
)
|
||||||
|
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sig_ready(request, room_id):
|
||||||
|
"""Toggle ready/unready for the polarity-room countdown.
|
||||||
|
POST body: action=ready|unready [, seconds_remaining=<int>]
|
||||||
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
user_seat = _canonical_user_seat(room, request.user)
|
||||||
|
if user_seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
action = request.POST.get("action", "ready")
|
||||||
|
reservation = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
|
|
||||||
|
if action == "ready":
|
||||||
|
if reservation is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
if reservation.ready:
|
||||||
|
return HttpResponse(status=200) # idempotent — already ready, don't re-trigger countdown
|
||||||
|
reservation.ready = True
|
||||||
|
reservation.save(update_fields=["ready"])
|
||||||
|
card = reservation.card
|
||||||
|
if card and card.arcana == TarotCard.MIDDLE:
|
||||||
|
_pol_prefix = "Leavened" if reservation.polarity == SigReservation.LEVITY else "Graven"
|
||||||
|
_card_display = f"{_pol_prefix} {card.name_title}"
|
||||||
|
elif card and card.arcana == TarotCard.MAJOR:
|
||||||
|
_base = card.name_title.removeprefix("The ")
|
||||||
|
_pol_suffix = "of Light" if reservation.polarity == SigReservation.LEVITY else "from the Grave"
|
||||||
|
_card_display = f"{_base} {_pol_suffix}"
|
||||||
|
else:
|
||||||
|
_card_display = card.name_title if card else "a card"
|
||||||
|
record(room, GameEvent.SIG_READY, actor=request.user,
|
||||||
|
card_name=_card_display,
|
||||||
|
corner_rank=card.corner_rank if card else "",
|
||||||
|
suit_icon=card.suit_icon if card else "")
|
||||||
|
# Retract the most recent un-retracted SIG_UNREADY (cancellation is now moot)
|
||||||
|
prior_unready = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_UNREADY
|
||||||
|
).last()
|
||||||
|
if prior_unready and not prior_unready.data.get("retracted"):
|
||||||
|
prior_unready.data["retracted"] = True
|
||||||
|
prior_unready.save(update_fields=["data"])
|
||||||
|
|
||||||
|
# Check if all three in this polarity are now ready
|
||||||
|
polarity = reservation.polarity
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
ready_count = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity, ready=True
|
||||||
|
).count()
|
||||||
|
if ready_count == 3:
|
||||||
|
from apps.epic.tasks import schedule_polarity_confirm
|
||||||
|
# Use saved countdown_remaining if a pause was recorded, else 12
|
||||||
|
saved = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity
|
||||||
|
).exclude(countdown_remaining__isnull=True).values_list(
|
||||||
|
"countdown_remaining", flat=True
|
||||||
|
).first()
|
||||||
|
seconds = saved if saved is not None else 12
|
||||||
|
schedule_polarity_confirm(str(room_id), polarity, seconds)
|
||||||
|
_notify_countdown_start(room_id, polarity, seconds=seconds)
|
||||||
|
|
||||||
|
else: # unready
|
||||||
|
if reservation is not None:
|
||||||
|
reservation.ready = False
|
||||||
|
reservation.save(update_fields=["ready"])
|
||||||
|
# Mark the most recent un-retracted SIG_READY event for this actor
|
||||||
|
prior = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_READY
|
||||||
|
).last()
|
||||||
|
if prior and not prior.data.get("retracted"):
|
||||||
|
prior.data["retracted"] = True
|
||||||
|
prior.save(update_fields=["data"])
|
||||||
|
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||||
|
polarity = reservation.polarity
|
||||||
|
|
||||||
|
# Save remaining seconds on all polarity reservations
|
||||||
|
try:
|
||||||
|
seconds_remaining = int(request.POST.get("seconds_remaining", 12))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
seconds_remaining = 12
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity).update(
|
||||||
|
countdown_remaining=seconds_remaining
|
||||||
|
)
|
||||||
|
_notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
|
||||||
|
from apps.epic.tasks import cancel_polarity_confirm
|
||||||
|
cancel_polarity_confirm(str(room_id), polarity)
|
||||||
|
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sig_confirm(request, room_id):
|
||||||
|
"""No-op: polarity confirmation is now driven server-side by threading.Timer in tasks.py."""
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def select_sig(request, room_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return redirect(
|
||||||
|
"epic:room" if room.table_status else "epic:gatekeeper",
|
||||||
|
room_id=room_id,
|
||||||
|
)
|
||||||
|
active_seat = active_sig_seat(room)
|
||||||
|
if active_seat is None or active_seat.gamer != request.user:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
card_id = request.POST.get("card_id")
|
||||||
|
try:
|
||||||
|
card = TarotCard.objects.get(pk=card_id)
|
||||||
|
except TarotCard.DoesNotExist:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
sig_card_ids = {c.pk for c in sig_deck_cards(room)}
|
||||||
|
if card.pk not in sig_card_ids:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
if room.table_seats.filter(significator=card).exists():
|
||||||
|
return HttpResponse(status=409)
|
||||||
|
active_seat.significator = card
|
||||||
|
active_seat.save()
|
||||||
|
deck_type = request.POST.get('deck_type', 'levity')
|
||||||
|
_notify_sig_selected(room_id, card.pk, active_seat.role, deck_type)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def tarot_deck(request, room_id):
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
deck_variant = request.user.equipped_deck
|
||||||
|
deck, _ = TarotDeck.objects.get_or_create(
|
||||||
|
room=room,
|
||||||
|
defaults={"deck_variant": deck_variant},
|
||||||
|
)
|
||||||
|
return render(request, "apps/gameboard/tarot_deck.html", {
|
||||||
|
"room": room,
|
||||||
|
"deck": deck,
|
||||||
|
"remaining": deck.remaining_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def tarot_deal(request, room_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("epic:tarot_deck", room_id=room_id)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
deck = TarotDeck.objects.get(room=room)
|
||||||
|
drawn = deck.draw(6) # Celtic Cross: 6 cross positions; 4 staff filled via gameplay
|
||||||
|
positions = [
|
||||||
|
{
|
||||||
|
"card": card,
|
||||||
|
"reversed": is_reversed,
|
||||||
|
"orientation": "Reversed" if is_reversed else "Upright",
|
||||||
|
"position": i + 1,
|
||||||
|
}
|
||||||
|
for i, (card, is_reversed) in enumerate(drawn)
|
||||||
|
]
|
||||||
|
return render(request, "apps/gameboard/tarot_deck.html", {
|
||||||
|
"room": room,
|
||||||
|
"deck": deck,
|
||||||
|
"remaining": deck.remaining_count,
|
||||||
|
"positions": positions,
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
165
src/apps/gameboard/static/apps/gameboard/game-kit.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
function initGameKitPage() {
|
||||||
|
const dialog = document.getElementById('id_tarot_fan_dialog');
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
const fanContent = document.getElementById('id_fan_content');
|
||||||
|
const prevBtn = document.getElementById('id_fan_prev');
|
||||||
|
const nextBtn = document.getElementById('id_fan_next');
|
||||||
|
|
||||||
|
let currentDeckId = null;
|
||||||
|
let currentIndex = 0;
|
||||||
|
let cards = [];
|
||||||
|
|
||||||
|
function storageKey(deckId) {
|
||||||
|
return 'tarot-fan-' + deckId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePosition() {
|
||||||
|
if (currentDeckId !== null) {
|
||||||
|
sessionStorage.setItem(storageKey(currentDeckId), currentIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePosition(deckId) {
|
||||||
|
const saved = sessionStorage.getItem(storageKey(deckId));
|
||||||
|
return saved !== null ? parseInt(saved, 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardTransform(offset) {
|
||||||
|
const abs = Math.abs(offset);
|
||||||
|
return {
|
||||||
|
transform: 'translateX(' + (offset * 200) + 'px) rotateY(' + (offset * 22) + 'deg) scale(' + Math.max(0.3, 1 - abs * 0.15) + ')',
|
||||||
|
opacity: Math.max(0.15, 1 - abs * 0.25),
|
||||||
|
zIndex: 10 - abs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFan() {
|
||||||
|
const total = cards.length;
|
||||||
|
if (!total) return;
|
||||||
|
cards.forEach(function(card, i) {
|
||||||
|
let offset = i - currentIndex;
|
||||||
|
if (offset > total / 2) offset -= total;
|
||||||
|
if (offset < -total / 2) offset += total;
|
||||||
|
|
||||||
|
const abs = Math.abs(offset);
|
||||||
|
card.classList.toggle('fan-card--active', offset === 0);
|
||||||
|
|
||||||
|
if (abs > 3) {
|
||||||
|
card.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
card.style.display = '';
|
||||||
|
const t = cardTransform(offset);
|
||||||
|
card.style.transform = t.transform;
|
||||||
|
card.style.opacity = t.opacity;
|
||||||
|
card.style.zIndex = t.zIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFan(deckId) {
|
||||||
|
currentDeckId = deckId;
|
||||||
|
currentIndex = restorePosition(deckId);
|
||||||
|
|
||||||
|
fetch('/gameboard/game-kit/deck/' + deckId + '/')
|
||||||
|
.then(function(r) { return r.text(); })
|
||||||
|
.then(function(html) {
|
||||||
|
fanContent.innerHTML = html;
|
||||||
|
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
|
||||||
|
if (currentIndex >= cards.length) currentIndex = 0;
|
||||||
|
cards.forEach(function(c) {
|
||||||
|
c.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
|
||||||
|
});
|
||||||
|
updateFan();
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFan() {
|
||||||
|
savePosition();
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(delta) {
|
||||||
|
if (!cards.length) return;
|
||||||
|
currentIndex = (currentIndex + delta + cards.length) % cards.length;
|
||||||
|
savePosition();
|
||||||
|
updateFan();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step through multiple cards one at a time so intermediate cards are visible
|
||||||
|
var _navTimer = null;
|
||||||
|
function navigateAnimated(steps) {
|
||||||
|
if (!cards.length || steps === 0) return;
|
||||||
|
clearTimeout(_navTimer);
|
||||||
|
var sign = steps > 0 ? 1 : -1;
|
||||||
|
var remaining = Math.abs(steps);
|
||||||
|
function tick() {
|
||||||
|
navigate(sign);
|
||||||
|
remaining--;
|
||||||
|
if (remaining > 0) _navTimer = setTimeout(tick, 60);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on the dark backdrop (the dialog or fan-wrap itself, not on any card child) closes
|
||||||
|
var fanWrap = dialog.querySelector('.tarot-fan-wrap');
|
||||||
|
dialog.addEventListener('click', function(e) {
|
||||||
|
if (e.target === dialog || e.target === fanWrap) closeFan();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Arrow key navigation
|
||||||
|
dialog.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'ArrowRight') navigate(1);
|
||||||
|
if (e.key === 'ArrowLeft') navigate(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mousewheel navigation — accumulate delta, cap at 3 cards per event so fast
|
||||||
|
// spins don't overshoot; CSS transitions handle the visual smoothness.
|
||||||
|
var wheelAccum = 0;
|
||||||
|
var wheelDecayTimer = null;
|
||||||
|
var WHEEL_STEP = 150;
|
||||||
|
dialog.addEventListener('wheel', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(wheelDecayTimer);
|
||||||
|
wheelAccum += e.deltaY;
|
||||||
|
var steps = Math.trunc(wheelAccum / WHEEL_STEP);
|
||||||
|
if (steps !== 0) {
|
||||||
|
steps = Math.sign(steps) * Math.min(Math.abs(steps), 3);
|
||||||
|
wheelAccum -= steps * WHEEL_STEP;
|
||||||
|
navigate(steps);
|
||||||
|
}
|
||||||
|
wheelDecayTimer = setTimeout(function() { wheelAccum = 0; }, 200);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Touch/swipe navigation — uses navigateAnimated so intermediate cards are visible
|
||||||
|
var touchStartX = 0;
|
||||||
|
var touchStartY = 0;
|
||||||
|
var touchStartTime = 0;
|
||||||
|
dialog.addEventListener('touchstart', function(e) {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
touchStartTime = Date.now();
|
||||||
|
}, { passive: true });
|
||||||
|
dialog.addEventListener('touchend', function(e) {
|
||||||
|
var dx = e.changedTouches[0].clientX - touchStartX;
|
||||||
|
var dy = e.changedTouches[0].clientY - touchStartY;
|
||||||
|
if (Math.abs(dy) > Math.abs(dx)) return; // vertical swipe — ignore
|
||||||
|
if (Math.abs(dx) < 60) return; // dead zone — raise to 40–60 for more deliberate swipe required
|
||||||
|
var elapsed = Math.max(1, Date.now() - touchStartTime);
|
||||||
|
var velocity = Math.abs(dx) / elapsed; // px/ms
|
||||||
|
var steps = velocity > 0.8 // flick threshold — raise (e.g. 0.6) so more swipes use drag formula
|
||||||
|
? Math.max(1, Math.round(velocity * 4)) // flick multiplier — lower (e.g. 4–5) to reduce cards per fast flick
|
||||||
|
: Math.round(Math.abs(dx) / 150); // slow-drag divisor — raise (e.g. 120–150) for fewer cards per short drag
|
||||||
|
navigateAnimated(dx < 0 ? steps : -steps);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', function() { navigate(-1); });
|
||||||
|
nextBtn.addEventListener('click', function() { navigate(1); });
|
||||||
|
|
||||||
|
document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function(card) {
|
||||||
|
card.addEventListener('click', function() { openFan(card.dataset.deckId); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initGameKitPage);
|
||||||
@@ -9,6 +9,11 @@ function initGameKitTooltips() {
|
|||||||
const gameKit = document.getElementById('id_game_kit');
|
const gameKit = document.getElementById('id_game_kit');
|
||||||
if (!portal || !miniPortal || !gameKit) return;
|
if (!portal || !miniPortal || !gameKit) return;
|
||||||
|
|
||||||
|
// Start portals hidden — ensures is_displayed() works correctly in tests
|
||||||
|
// that run without CSS (StaticLiveServerTestCase).
|
||||||
|
portal.style.display = 'none';
|
||||||
|
miniPortal.style.display = 'none';
|
||||||
|
|
||||||
let equippedId = gameKit.dataset.equippedId || '';
|
let equippedId = gameKit.dataset.equippedId || '';
|
||||||
let activeToken = null;
|
let activeToken = null;
|
||||||
let equipping = false;
|
let equipping = false;
|
||||||
@@ -19,8 +24,9 @@ function initGameKitTooltips() {
|
|||||||
|
|
||||||
function closePortals() {
|
function closePortals() {
|
||||||
portal.classList.remove('active');
|
portal.classList.remove('active');
|
||||||
|
portal.style.display = 'none';
|
||||||
miniPortal.classList.remove('active');
|
miniPortal.classList.remove('active');
|
||||||
miniPortal.style.display = '';
|
miniPortal.style.display = 'none';
|
||||||
activeToken = null;
|
activeToken = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,33 +50,66 @@ function initGameKitTooltips() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildMiniContent(tokenId) {
|
// buildMiniContent takes the full element so it can inspect data-deck-id vs data-token-id.
|
||||||
if (equippedId && tokenId === equippedId) {
|
function buildMiniContent(token) {
|
||||||
miniPortal.textContent = 'Equipped';
|
const deckId = token.dataset.deckId;
|
||||||
} else {
|
const tokenId = token.dataset.tokenId;
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.className = 'equip-trinket-btn';
|
if (deckId) {
|
||||||
btn.dataset.tokenId = tokenId;
|
const equippedDeckId = gameKit.dataset.equippedDeckId || '';
|
||||||
btn.textContent = 'Equip Trinket?';
|
if (equippedDeckId && deckId === equippedDeckId) {
|
||||||
btn.addEventListener('click', (e) => {
|
miniPortal.textContent = 'Equipped';
|
||||||
e.stopPropagation();
|
} else {
|
||||||
equipping = true;
|
const btn = document.createElement('button');
|
||||||
equippedId = tokenId;
|
btn.className = 'equip-deck-btn';
|
||||||
gameKit.dataset.equippedId = equippedId;
|
btn.textContent = 'Equip Deck?';
|
||||||
fetch(`/gameboard/equip-trinket/${tokenId}/`, {
|
btn.addEventListener('click', (e) => {
|
||||||
method: 'POST',
|
e.stopPropagation();
|
||||||
headers: {'X-CSRFToken': getCsrfToken()},
|
equipping = true;
|
||||||
}).then(r => {
|
gameKit.dataset.equippedDeckId = deckId;
|
||||||
if (r.ok && equipping) {
|
fetch(`/gameboard/equip-deck/${deckId}/`, {
|
||||||
equipping = false;
|
method: 'POST',
|
||||||
closePortals();
|
headers: {'X-CSRFToken': getCsrfToken()},
|
||||||
} else {
|
}).then(r => {
|
||||||
equipping = false;
|
if (r.ok && equipping) {
|
||||||
}
|
equipping = false;
|
||||||
|
closePortals();
|
||||||
|
} else {
|
||||||
|
equipping = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
miniPortal.innerHTML = '';
|
||||||
miniPortal.innerHTML = '';
|
miniPortal.appendChild(btn);
|
||||||
miniPortal.appendChild(btn);
|
}
|
||||||
|
} else if (tokenId) {
|
||||||
|
if (equippedId && tokenId === equippedId) {
|
||||||
|
miniPortal.textContent = 'Equipped';
|
||||||
|
} else {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'equip-trinket-btn';
|
||||||
|
btn.dataset.tokenId = tokenId;
|
||||||
|
btn.textContent = 'Equip Trinket?';
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
equipping = true;
|
||||||
|
equippedId = tokenId;
|
||||||
|
gameKit.dataset.equippedId = equippedId;
|
||||||
|
fetch(`/gameboard/equip-trinket/${tokenId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-CSRFToken': getCsrfToken()},
|
||||||
|
}).then(r => {
|
||||||
|
if (r.ok && equipping) {
|
||||||
|
equipping = false;
|
||||||
|
closePortals();
|
||||||
|
} else {
|
||||||
|
equipping = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
miniPortal.innerHTML = '';
|
||||||
|
miniPortal.appendChild(btn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,18 +119,19 @@ function initGameKitTooltips() {
|
|||||||
const tooltip = token.querySelector('.token-tooltip');
|
const tooltip = token.querySelector('.token-tooltip');
|
||||||
portal.innerHTML = tooltip.innerHTML;
|
portal.innerHTML = tooltip.innerHTML;
|
||||||
portal.classList.add('active');
|
portal.classList.add('active');
|
||||||
|
portal.style.display = 'block';
|
||||||
|
|
||||||
const isEquippable = !!token.dataset.tokenId;
|
const isEquippable = !!(token.dataset.tokenId || token.dataset.deckId);
|
||||||
let miniHeight = 0;
|
let miniHeight = 0;
|
||||||
|
|
||||||
if (isEquippable) {
|
if (isEquippable) {
|
||||||
buildMiniContent(token.dataset.tokenId);
|
buildMiniContent(token);
|
||||||
miniPortal.classList.add('active');
|
miniPortal.classList.add('active');
|
||||||
miniPortal.style.display = 'block';
|
miniPortal.style.display = 'block';
|
||||||
miniHeight = miniPortal.offsetHeight + 4;
|
miniHeight = miniPortal.offsetHeight + 4;
|
||||||
} else {
|
} else {
|
||||||
miniPortal.classList.remove('active');
|
miniPortal.classList.remove('active');
|
||||||
miniPortal.style.display = '';
|
miniPortal.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenRect = token.getBoundingClientRect();
|
const tokenRect = token.getBoundingClientRect();
|
||||||
@@ -99,8 +139,18 @@ function initGameKitTooltips() {
|
|||||||
const rawLeft = tokenRect.left + tokenRect.width / 2;
|
const rawLeft = tokenRect.left + tokenRect.width / 2;
|
||||||
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
||||||
portal.style.left = Math.round(clampedLeft) + 'px';
|
portal.style.left = Math.round(clampedLeft) + 'px';
|
||||||
portal.style.top = Math.round(tokenRect.top) + 'px';
|
|
||||||
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
// Show above when token is in lower viewport half; below when in upper half
|
||||||
|
// (avoids clipping when game-kit tokens sit near the top in landscape mode).
|
||||||
|
const tokenCenterY = tokenRect.top + tokenRect.height / 2;
|
||||||
|
const showBelow = tokenCenterY < window.innerHeight / 2;
|
||||||
|
if (showBelow) {
|
||||||
|
portal.style.top = Math.round(tokenRect.bottom) + 'px';
|
||||||
|
portal.style.transform = 'translate(-50%, 0.5rem)';
|
||||||
|
} else {
|
||||||
|
portal.style.top = Math.round(tokenRect.top) + 'px';
|
||||||
|
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEquippable) {
|
if (isEquippable) {
|
||||||
const mainRect = portal.getBoundingClientRect();
|
const mainRect = portal.getBoundingClientRect();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.models import Applet, UserApplet
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import Token, User
|
||||||
|
|
||||||
|
|
||||||
class GameboardViewTest(TestCase):
|
class GameboardViewTest(TestCase):
|
||||||
@@ -50,8 +50,11 @@ class GameboardViewTest(TestCase):
|
|||||||
def test_game_kit_has_free_token(self):
|
def test_game_kit_has_free_token(self):
|
||||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
|
||||||
|
|
||||||
def test_game_kit_has_card_deck_placeholder(self):
|
def test_game_kit_shows_deck_variant_cards(self):
|
||||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck")
|
decks = self.parsed.cssselect("#id_game_kit .deck-variant")
|
||||||
|
self.assertGreater(len(decks), 0)
|
||||||
|
# Earthman deck (seeded by migration) should have its own card
|
||||||
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")
|
||||||
|
|
||||||
def test_game_kit_has_dice_set_placeholder(self):
|
def test_game_kit_has_dice_set_placeholder(self):
|
||||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
|
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
|
||||||
@@ -101,3 +104,139 @@ class ToggleGameAppletsViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
|
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
|
||||||
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())
|
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class GameKitViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"})
|
||||||
|
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
|
||||||
|
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
|
||||||
|
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
|
||||||
|
response = self.client.get("/gameboard/game-kit/")
|
||||||
|
self.parsed = lxml.html.fromstring(response.content)
|
||||||
|
|
||||||
|
def test_game_kit_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/gameboard/game-kit/")
|
||||||
|
self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_game_kit_shows_gear_btn(self):
|
||||||
|
[_] = self.parsed.cssselect(".gear-btn")
|
||||||
|
|
||||||
|
def test_game_kit_shows_applet_menu(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_game_kit_menu")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_trinkets_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_tokens_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_decks_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_dice_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_sections_container_present(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_gk_sections_container")
|
||||||
|
|
||||||
|
def test_all_sections_visible_by_default(self):
|
||||||
|
sections = self.parsed.cssselect("#id_gk_sections_container section")
|
||||||
|
self.assertEqual(len(sections), 4)
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleGameKitSectionsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.trinkets, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.tokens, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.decks, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.dice, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.url = reverse("toggle_game_kit_sections")
|
||||||
|
|
||||||
|
def test_unauthenticated_user_is_redirected(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_unchecked_section_gets_user_applet_with_visible_false(self):
|
||||||
|
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
|
||||||
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
|
def test_redirects_on_normal_post(self):
|
||||||
|
response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]})
|
||||||
|
self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_returns_200_on_htmx_post(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["gk-trinkets", "gk-tokens"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_does_not_affect_gameboard_applets(self):
|
||||||
|
gb_applet, _ = Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
||||||
|
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists())
|
||||||
|
|
||||||
|
def test_hidden_section_absent_from_htmx_response(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["gk-trinkets"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
sections = parsed.cssselect("section")
|
||||||
|
self.assertEqual(len(sections), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class EquipTrinketViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first()
|
||||||
|
|
||||||
|
def test_get_returns_trinket_button_partial(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("equip_trinket", kwargs={"token_id": self.token.pk})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html")
|
||||||
|
|
||||||
|
|
||||||
|
class TarotFanViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from apps.epic.models import DeckVariant
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||||
|
self.user = User.objects.create(email="fan@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_returns_fan_partial_for_unlocked_deck(self):
|
||||||
|
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html")
|
||||||
|
|
||||||
|
def test_returns_403_for_locked_deck(self):
|
||||||
|
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|||||||
@@ -7,5 +7,9 @@ urlpatterns = [
|
|||||||
path('', views.gameboard, name='gameboard'),
|
path('', views.gameboard, name='gameboard'),
|
||||||
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
|
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
|
||||||
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
|
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
|
||||||
|
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
|
||||||
|
path('game-kit/', views.game_kit, name='game_kit'),
|
||||||
|
path('game-kit/toggle-sections', views.toggle_game_kit_sections, name='toggle_game_kit_sections'),
|
||||||
|
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from apps.applets.utils import applet_context
|
from apps.applets.utils import applet_context
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.models import Applet, UserApplet
|
||||||
from apps.epic.models import Room, RoomInvite
|
from apps.epic.models import DeckVariant, Room, RoomInvite
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ def gameboard(request):
|
|||||||
"coin": coin,
|
"coin": coin,
|
||||||
"carte": carte,
|
"carte": carte,
|
||||||
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
||||||
|
"equipped_deck_id": str(request.user.equipped_deck_id or ""),
|
||||||
|
"deck_variants": list(request.user.unlocked_decks.all()),
|
||||||
"free_tokens": free_tokens,
|
"free_tokens": free_tokens,
|
||||||
"free_count": len(free_tokens),
|
"free_count": len(free_tokens),
|
||||||
"applets": applet_context(request.user, "gameboard"),
|
"applets": applet_context(request.user, "gameboard"),
|
||||||
@@ -59,6 +61,8 @@ def toggle_game_applets(request):
|
|||||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||||
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
||||||
|
"equipped_deck_id": str(request.user.equipped_deck_id or ""),
|
||||||
|
"deck_variants": list(request.user.unlocked_decks.all()),
|
||||||
"free_tokens": list(request.user.tokens.filter(
|
"free_tokens": list(request.user.tokens.filter(
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
).order_by("expires_at")),
|
).order_by("expires_at")),
|
||||||
@@ -86,3 +90,72 @@ def equip_trinket(request, token_id):
|
|||||||
"apps/gameboard/_partials/_equip_trinket_btn.html",
|
"apps/gameboard/_partials/_equip_trinket_btn.html",
|
||||||
{"token": token},
|
{"token": token},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def equip_deck(request, deck_id):
|
||||||
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
||||||
|
if request.method == "POST":
|
||||||
|
request.user.equipped_deck = deck
|
||||||
|
request.user.save(update_fields=["equipped_deck"])
|
||||||
|
return HttpResponse(status=204)
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
|
||||||
|
|
||||||
|
def _game_kit_context(user):
|
||||||
|
coin = user.tokens.filter(token_type=Token.COIN).first()
|
||||||
|
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
|
||||||
|
carte = user.tokens.filter(token_type=Token.CARTE).first()
|
||||||
|
free_tokens = list(user.tokens.filter(
|
||||||
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
|
).order_by("expires_at"))
|
||||||
|
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
||||||
|
return {
|
||||||
|
"coin": coin,
|
||||||
|
"pass_token": pass_token,
|
||||||
|
"carte": carte,
|
||||||
|
"free_tokens": free_tokens,
|
||||||
|
"tithe_tokens": tithe_tokens,
|
||||||
|
"unlocked_decks": list(user.unlocked_decks.all()),
|
||||||
|
"applets": applet_context(user, "game-kit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def game_kit(request):
|
||||||
|
return render(request, "apps/gameboard/game_kit.html", {
|
||||||
|
**_game_kit_context(request.user),
|
||||||
|
"page_class": "page-gameboard",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def toggle_game_kit_sections(request):
|
||||||
|
checked = request.POST.getlist("applets")
|
||||||
|
for applet in Applet.objects.filter(context="game-kit"):
|
||||||
|
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/gameboard/_partials/_game_kit_sections.html",
|
||||||
|
_game_kit_context(request.user))
|
||||||
|
return redirect("game_kit")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def tarot_fan(request, deck_id):
|
||||||
|
from apps.epic.models import TarotCard
|
||||||
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
||||||
|
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
_suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "CROWNS": 3, "COINS": 4}
|
||||||
|
cards = sorted(
|
||||||
|
TarotCard.objects.filter(deck_variant=deck),
|
||||||
|
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
|
||||||
|
)
|
||||||
|
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
|
||||||
|
"deck": deck,
|
||||||
|
"cards": cards,
|
||||||
|
})
|
||||||
|
|||||||
20
src/apps/lyric/migrations/0014_user_equipped_deck.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 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', '0009_deckvariant_alter_tarotcard_options_and_more'),
|
||||||
|
('lyric', '0013_alter_token_slots_claimed'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='equipped_deck',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='epic.deckvariant'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/apps/lyric/migrations/0015_user_unlocked_decks.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-25 02:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0010_seed_deck_variants_and_earthman'),
|
||||||
|
('lyric', '0014_user_equipped_deck'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='unlocked_decks',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='unlocked_by', to='epic.deckvariant'),
|
||||||
|
),
|
||||||
|
]
|
||||||
24
src/apps/lyric/migrations/0016_backfill_unlocked_decks.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_unlocked_decks(apps, schema_editor):
|
||||||
|
User = apps.get_model("lyric", "User")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
try:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
except DeckVariant.DoesNotExist:
|
||||||
|
return
|
||||||
|
for user in User.objects.filter(unlocked_decks__isnull=True):
|
||||||
|
user.unlocked_decks.add(earthman)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("lyric", "0015_user_unlocked_decks"),
|
||||||
|
("epic", "0010_seed_deck_variants_and_earthman"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_unlocked_decks, migrations.RunPython.noop),
|
||||||
|
]
|
||||||