Compare commits

...
Author SHA1 Message Date
retoor 055c7bcd07 Merge pull request 'Opinion Wars: week-long two-faction battles on posts' (#173) from blindxfish/devplacepy:OpinionWar into master
DevPlace CI / test (push) Failing after 1h23m35s
Reviewed-on: #173
2026-08-22 07:42:14 +02:00
blindxfishandClaude Fable 5 50baf9d6f1 Fix battles join docs param location and the fight-flow ticker assertion
DevPlace CI / test (pull_request) Has been cancelled
The auth matrix probes documented endpoints with their documented form
params; the faction param was declared with location body instead of
form, so the anonymous probe sent an empty body and hit 422
validation before the auth guard. And the first fight always triggers
a lead-change event that outranks the fight event at the top of the
ticker, so the e2e flow now asserts on the ticker as a whole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 00:05:00 +02:00
blindxfishandClaude Fable 5 80956ce0f4 Add Opinion Wars: week-long two-faction battles attached to posts
A new post attachment type beside polls: the composer gains a Start
Opinion War builder (same disabled-inputs opt-in as the poll builder)
that names exactly two factions; the battle runs for exactly 7 days
from post creation. Members join a side, may defect at any time
(damage already dealt stays with the faction it was dealt to), and
fight once per 24 hours per battle. A fight spends 25 Code Farm coins
and deals deterministic level-weighted damage: 100 + 10 * min(level,
20) HP, so a newcomer deals 110 and a veteran caps at 300 - no
randomness anywhere.

The battle renders on the post card as a CSS pixel-art battlefield
(box-shadow sprites: castles, faction flags, marching soldiers, a
flickering campfire; steps() animation, disabled under reduced motion)
with live HP bars, a countdown, the viewer's faction strip, top
contributors and an event ticker. Live frames ride pub/sub on
public.battle.{uid} via a relay on the service-lock owner, with the
durable opinion_war_events trail (per-war atomic seq) as the source of
truth and a 15s incremental poller as fallback. /battles lists battles
with active/ended/mine filters, search and pagination.

Every mutation is a conditional UPDATE via conditional_update_row: the
fight sequence claims the cooldown first, then spends coins, then lands
the damage, compensating earlier steps on any later refusal so a crash
costs a turn, never coins. Resolution is lazy on read (no cron):
an exactly-once CAS computes the winner in the statement, awards XP
(participation, winner bonus, top damage dealer bonus; draws pay
participation only), emits the result event and notifies fighters. The
OpinionWarService backstop resolves unviewed wars and sends
fight-ready notifications, exactly-once via a marker CAS.

Fan-out: battle notification type, four badges, audit keys
(battle.create/join/switch/fight/resolve), Devii actions (join/fight
confirm-gated), API docs group, docs prose page, sitemap and topnav
entries, REPORTABLE_TARGETS registration, post-delete cascades,
README and nested CLAUDE.md documentation.

Verified with the four-layer procedure: property checks over the full
damage domain, 1200-step stateful fuzz (hp-sum invariant, coins never
negative, resolved totals frozen), and real 8-process races proving
exactly-once semantics for concurrent fights, double-spends across two
wars, resolution XP and double-joins. Persisted tests in
tests/unit/services/opinionwar, tests/api/battles, tests/e2e/battles
and tests/api/posts/create.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:30:02 +02:00
retoor 2f26dbb1e7 Update
DevPlace CI / test (push) Failing after 1h42m39s
2026-08-20 00:38:07 +02:00
retoor 516219513a Merge pull request 'Show a post's image on its card, and show it full size' (#172) from blindxfish/devplacepy:PostCardPolish into master
DevPlace CI / test (push) Failing after 1h34m22s
Reviewed-on: #172
2026-08-19 22:11:02 +02:00
blindxfishandClaude Opus 5 8856c38b4d Show a post's image on its card, and show it full size
DevPlace CI / test (pull_request) Has been cancelled
_attachment_display.html iterates a context variable named
`attachments`, so every caller binds it before the include.
_post_card.html was the one caller that did not: it guarded on
item.attachments but included the partial with nothing bound, so the
gallery looped over whatever `attachments` happened to be in the
surrounding page context and rendered empty. Every post with an image
looked image-less on the feed and on profiles, and on a project page -
where project_detail.html sets `attachments` at template scope for the
project's own files - a devlog card would have rendered the project's
files as its own.

With the image actually reaching the card, render a lone one properly:
a gallery holding exactly one item gets a `single` class and takes the
full content column (max-height 480px, object-fit contain, no hover
scale), matching the original DevPlace. That branch serves the stored
original rather than thumbnail_url, because a thumbnail is 200px on its
longest side and stretching it to the column width is visibly blurry.

Animated GIFs needed no change and now have a test proving it: they
never had a thumbnail to flatten, so they already took the original-file
path and simply render larger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:14:26 +02:00
retoor 08d370b020 Merge pull request 'Attach images pasted into the composer, comments and chat' (#171) from blindxfish/devplacepy:PasteImage into master
DevPlace CI / test (push) Failing after 1h31m51s
Reviewed-on: #171
2026-08-17 22:44:45 +02:00
blindxfishandClaude Opus 5 076f55f380 Attach images pasted into the composer, comments and chat
DevPlace CI / test (pull_request) Has been cancelled
Pressing Ctrl+V with a screenshot on the clipboard now attaches it
immediately instead of requiring a trip through the file picker.

The clipboard reader lives in dp-upload behind a new opt-in `paste`
boolean attribute: with it set, the component binds one paste listener
on its closest form and routes the clipboard image files through the
same handleFiles path as the picker and the drop target, so validation,
limits, the terms gate and the hidden attachment_uids field are shared.
A paste carrying plain text is never swallowed.

It is opt-in rather than a form-wide default because a form may hold
several upload buttons - projects.html has cover and logo beside the
attachment one - and a default would attach one pasted screenshot to
all of them.

Set on _attachment_form.html, so every form including it inherits the
behaviour (post composer, post edit, gists, projects, issues,
screenshots), plus _comment_form.html, messages.html and the embed-mode
skeleton AppChat builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:23:25 +02:00
retoor e0672f896d Merge pull request 'feat: Implement links to the official iOS app in the web app' (#170) from typosaurus/169-implement-links-to-the-official-ios-app-in-the-web-app into master
DevPlace CI / test (push) Failing after 26m50s
Reviewed-on: #170
2026-08-16 06:37:41 +02:00
typosaurus c9802440d7 test(sveta): Write e2e tests for the iOS app badges
DevPlace CI / test (pull_request) Failing after 28m47s
Outcome: done
Changed: tests/e2e/iosapp.py:1-94 (new file)
Verified by: python -m pytest tests/e2e/iosapp.py -q -> 5 passed (3 consecutive clean runs, incl. verify()); full make test -> 3348 passed, 1 skipped, 11 failed (9 tests/api/projects/workspace.py FileNotFoundError 'docker' = missing docker binary in container, pre-existing per sibling run; 1 tests/e2e/game/farm.py steal flake, passes standalone; 1 was this file's pre-fix settings-propagation race, fixed)
Findings: tests/e2e/iosapp.py locks the badge behaviour: footer badge visible on landing (.landing-footer) and /feed (.site-footer), guest topnav badge next to Login/Sign Up, mobile-panel badge after opening the hamburger, every href equals the configured ios_app_url with target=_blank rel=noopener noreferrer, and all badges disappear when ios_app_url is cleared (restored in try/finally).
Settings-flip e2e tests must sleep CACHE_VERSION_PROPAGATION_SECONDS (1.5s) after set_setting before navigating: the server caches the cache_state version for 1s (core.py:56), and the first iosapp run hit that race (badge absent on a stale render).
On a fresh DB the operational_defaults block incl. ios_app_url is never seeded because it is gated on the pre-init db.tables snapshot (schema.py:34 vs :1705), so the badge renders only after an admin sets ios_app_url.
Open: re-run make test on a docker-capable host to clear the 9 workspace.py environment failures; decide the fresh-DB ios_app_url seeding gap (PM/implementation node); farm s

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: e8e69f65a5fa40c2bf6a8b0206cfb2d4
Typosaurus-Agent: @sveta
Refs: #169
2026-08-16 03:59:11 +00:00
typosaurus 15f04e0d13 feat(nadia): Implement iOS app badges in footer, topnav, and mobile menu
Outcome: done
Changed: devplacepy/templates/base.html:126-132,189-195; devplacepy/templates/_footer_links.html:12-19; devplacepy/templates/admin_settings.html:33-38; devplacepy/routers/admin/settings.py:19; devplacepy/templating.py:197-199,218; devplacepy/static/css/base.css:720-731,785-793,1151-1161,1210-1212; devplacepy/database/CLAUDE.md:224,228
Verified by: import gate clean; py_compile clean; ruff/pyflakes no new findings; TestClient smoke (badges present with href=ios_app_url, target=_blank rel=noopener noreferrer, static_url-versioned src on /, /feed, /leaderboard; absent when setting empty; admin clear via /admin/settings POST verified); make test-fast: 2659 passed, 1 skipped, 10 failed - all environmental (9 missing docker binary, 1 missing libcairo.so.2), none caused by this change; Playwright e2e inapplicable (chromium cannot launch: libglib-2.0.so.0 missing)
Findings: Badges render only when ios_app_url site setting is non-empty and are editable/clearable via AdminSettingsForm (CLEARABLE_SETTINGS). Researched URL https://apps.apple.com/app/devplace/id6797215143 (trackId 6797215143, bundleId wok.DevPlaceIOS) confirmed via iTunes Lookup API and seeded in schema.py operational_defaults by sibling commit. Topnav badge hides at <=1024px where the mobile panel badge takes over, keeping the 360px layout overflow-free. Badge SVG is Apple's official white variant vendored at devplacepy/static/app-store-badge.svg with retoor header. Apple badge marketing guidance (40px min

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 2817fd803023406699936e88b76ee632
Typosaurus-Agent: @nadia
Refs: #169
2026-08-16 03:59:11 +00:00
typosaurus 551d540bc7 feat(zoya): Review the research findings against the node acceptance criteria
Outcome: done
Changed: none
Verified by: not verified - review-only node; repo cross-checks done (devplacepy/templates/docs/contact.html:5, devplacepy/templates/admin_settings.html:192, devplacepy/database/schema.py:1746, devplacepy/static/app-store-badge.svg)
Findings: Verdict: accept with findings - every parent acceptance criterion is addressed by sourced, tiered findings; none of the notes below is blocking.
The verified URL is already consumed in-flight: uncommitted ios_app_url default at devplacepy/database/schema.py:1746 plus AdminSettingsForm field (devplacepy/models.py) and ios_app_url() Jinja global (devplacepy/templating.py); later nodes must not re-introduce it and must review that diff separately.
Apple's official badge SVG is vendored at devplacepy/static/app-store-badge.svg (title Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917, 119.66407x40), matching leaf-2's source recommendation; it sits at the static root rather than the leaf's suggested static/vendors/, a consistency-review call, and the leaf's own commit declared "Changed: none" despite adding it.
"Confirmed live" evidence is DuckDuckGo-indexed storefront snapshots (/fr/, /th/, /il/) via the sanctioned aggregator rsearch.app.molodetz.nl, direct egress to apps.apple.com being blocked and disclosed; corroborated by github.com/WilhelmOks/DevPlaceIOS, so a manual click-through before release is advised.
Apple's badge terms (min 40px onscreen height, one badge per layout) vs the ticket's "small badg

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 0c6857c89c9448589c0d0141ee774948
Typosaurus-Agent: @zoya
Refs: #169
2026-08-16 03:59:11 +00:00
typosaurus fd409ceea7 feat(vera): Verify the official DevPlace iOS app listing on the App Store
Outcome: done
Changed: none
Verified by: not verified (no files changed; evidence cross-checked from three live storefront variants of the same listing id)
Findings: Official DevPlace iOS app verified live on the App Store, retrieved 16/08/2026: name "DevPlace" (subtitle "The Developer Social Network"), developer "Wilhelm Oks", id 6797215143, canonical URL https://apps.apple.com/app/devplace/id6797215143 (confirmed live via /fr/, /th/, /il/ storefront variants).
Listing confirmed as the official DevPlace client: feature list and description match the DevPlace web app and point users to "DevPlace on the web"; cross-checked against devplacepy/templates/docs/contact.html:5 and devplacepy/templates/admin_settings.html:192, which state DevPlace declares trader info in the app stores that carry a DevPlace client.
Publisher on the listing is the personal name "Wilhelm Oks" (Provider; Copyright © 2026 Wilhelm Oks); the mapping to repo identity retoor@molodetz.nl is an assumption, not provable from inside the repo.
No App Store URL exists anywhere in the repo (grep 16/08/2026); the verified URL must be introduced as new, e.g. as a shared constant.
Direct egress to apps.apple.com and itunes.apple.com is blocked in this run; listing content was retrieved via the sanctioned aggregator rsearch.app.molodetz.nl (timestamps 2026-08-16T01:44-01:45Z).
Corroboration: github.com/WilhelmOks/DevPlaceIOS ("A native iOS app for DevPlace") matches the App Store developer name.
Open: badge graphic ass

Typosaurus-Run: 2910ed1ba2604be09c649141af1baf44
Typosaurus-Node: 42c9a7671c2e4a0e960c2adf44323954
Typosaurus-Agent: @vera
Refs: #169
2026-08-16 03:59:11 +00:00
93 changed files with 4510 additions and 25 deletions
+2
View File
@@ -155,6 +155,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
| `devplacepy/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
| `devplacepy/services/opinionwar/CLAUDE.md` | Opinion Wars (week-long faction battles on posts: atomic fight/resolve transitions, cooldown-before-coins compensation, event seq allocation, relay-on-lock-owner) |
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
@@ -201,6 +202,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
| `/game` | game/ package - see `services/game/CLAUDE.md` |
| `/reports`, `/admin/moderation`, `/workspaces` | reports.py, admin/moderation.py, workspaces.py - see `services/moderation/CLAUDE.md` |
| `/quizzes` | quizzes/ package - see `services/quiz/CLAUDE.md` |
| `/battles` | battles.py - see `services/opinionwar/CLAUDE.md` |
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
+21
View File
@@ -97,6 +97,7 @@ devplacepy/
| `/leaderboard` | Contributor ranking by total stars earned |
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
| `/battles` | **Opinion Wars**: week-long two-faction battles attached to posts, started from the composer's *Start Opinion War* builder. Members join a side and fight once a day (25 Code Farm coins, level-weighted damage); the pixel-art battle card shows live HP bars, a countdown, top contributors and an event ticker. `/battles` lists battles (`active`/`ended`/`mine` + search); `/battles/{uid}` state, `/battles/{uid}/events` replay, `/battles/{uid}/join` and `/battles/{uid}/fight` actions |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
@@ -169,6 +170,25 @@ scoreboard on the right. Guests read published quizzes and see the board; they c
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
`devplace quiz prune`.
## Opinion Wars
**Opinion Wars** (`/battles`) are week-long two-faction battles attached to posts, in the spirit of
old eRepublik battles: settle tabs-versus-spaces by showing up daily and fighting for your side.
- **Start one from the composer.** The *Start Opinion War* button next to *Add poll* names the two
factions; the battle runs for exactly 7 days from the moment the post is published.
- **Join and fight.** Any signed-in member picks a side and may fight once every 24 hours per
battle. A fight costs 25 Code Farm coins and deals deterministic, level-weighted damage
(100 HP + 10 per site level, capped at level 20) - no randomness, dedication wins wars.
- **Defection is allowed.** Switch factions any time; damage already dealt stays where it landed.
- **Live pixel-art card.** The battle renders on the post as a CSS pixel-art battlefield with HP
bars, a countdown, your rank, top contributors and a live event ticker (joins, defections,
fights, lead changes) over pub/sub with an incremental replay fallback.
- **Rewards.** When the week ends the bigger total wins: every fighter earns XP, the winning side
and the top damage dealer earn bonuses, and battle badges (*Instigator*, *First Blood*,
*War Veteran*, *Champion*) mark the milestones. Notifications cover lead changes, the result and
your next fight being ready.
## Code Farm
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.
@@ -202,6 +222,7 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
- **Emoji reactions** - react with **any** emoji on posts, comments, gists, and projects, separate from voting and carrying no ranking weight. A short quick-pick palette covers the common reactions, and a `+` button next to it opens the full searchable emoji picker (every standard emoji, including skin tones), so a reaction is never limited to a preset list. Emoji already used on an item are shown as counted chips beside the palette.
- **Emoji shortcodes** - typing a `:name:` shortcode in any content (posts, comments, titles, project and gist descriptions, news, and direct messages) renders the matching emoji, using the full GitHub/Discord standard set (for example `:rocket:` becomes a rocket). Server-rendered and live content share one shortcode list; unknown names and shortcodes inside code are left untouched. Documented at `/docs/emoji-shortcodes`. This is distinct from the visual emoji-picker button in the composer, which inserts the literal emoji character.
- **Polls** - a post can carry a poll (question plus up to six options); results appear as live bars once the viewer votes, one vote per member. A poll can be attached when the post is created or added later by editing a post that has none.
- **Paste an image to attach it** - pressing Ctrl+V (Cmd+V) with a screenshot or copied image on the clipboard while writing a post, a comment, a direct message, an issue, a gist, or a project attaches it immediately, with no trip through the file picker. The upload, its limits, and the resulting attachment are identical to picking the file by hand.
- **Bookmarks** - save posts, gists, projects, and news to a personal list at `/bookmarks/saved`.
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
+2
View File
@@ -97,6 +97,8 @@ QUIZ_SCOREBOARD_LIMIT = 20
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
QUIZ_LIST_PER_PAGE = 20
BATTLES_LIST_PER_PAGE = 10
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
DEFAULT_MODIFIER_PROMPT = (
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
+5
View File
@@ -53,6 +53,7 @@ from devplacepy.utils import (
XP_UPVOTE,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.opinionwar import store as war_store
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
@@ -627,6 +628,7 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
"war": detail.get("war"),
"project_link": detail.get("project_link"),
"maturity": detail.get("maturity", "general"),
}
@@ -817,6 +819,9 @@ def load_detail(
"reactions": reactions,
"bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
"war": war_store.get_war_serialized_for_post(item["uid"], user)
if target_type == "post"
else None,
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
"maturity": get_maturity(target_type, item["uid"])["level"],
}
+4 -1
View File
@@ -218,11 +218,12 @@ Site settings are seeded on startup (`site_settings` table):
| `moderation_mature_default_hidden` | `"1"` | Hide mature-labelled content behind an interstitial by default |
| `account_deletion_grace_hours` | `"24"` | Reversible window before a deleted account is purged |
| `contact_email` / `contact_phone` / `contact_address` | `""` | Published contact details, rendered on `/docs/contact.html` |
| `ios_app_url` | `"https://apps.apple.com/app/devplace/id6797215143"` | Official iOS app listing; the App Store badges in the footer, topnav and mobile menu link here and hide when the value is empty |
| `terms_version` / `privacy_version` / `guidelines_version` | `"1"` | Bumping `terms_version` forces re-acceptance before the next write. **Every reader uses `get_setting(key, "1") or "1"`** - an empty stored value must read as the default or the gate 403s every write |
| `ai_third_party_provider` | `""` | Named in the consent copy and the privacy policy |
| `extra_head` | `""` | Raw HTML emitted verbatim into every page `<head>` by `templating.extra_head_tag()`; site-wide trusted-admin input, not sanitized |
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` is the sole key in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea removes it (the default loop skips empty values).
Besides the site/news/upload keys above, the **Operational** group is admin-editable at `/admin/settings`: `site_url` (public origin for absolute links incl. container ingress; resolved by `seo.public_base_url()` = setting -> `DEVPLACE_SITE_URL` env -> request origin), `rate_limit_per_minute`, `rate_limit_window_seconds`, `news_service_interval`, `session_max_age_days`, `session_remember_days`, `registration_open`, `maintenance_mode`, `maintenance_message`, `docs_search_mode` (`agent`|`bm25`, default `agent` - picks the `/docs/search.html` surface: the in-page Devii chat or the classic BM25 list; a viewer over their daily AI limit, or a guest when Devii is disabled, auto-falls-back to BM25 via `routers/docs/views.py` `_agent_search_state`), `outbound_proxy_url` (empty by default - when set, every `stealth.stealth_async_client`/`stealth_sync_client` call across the whole app routes through it via `stealth.configured_proxy_url()`; validated as `http(s)://`/`socks5(h)://` with a host in `AdminSettingsForm`; falls back to `DEVPLACE_OUTBOUND_PROXY_URL` when unset - see the "Outbound HTTP" note in the root `CLAUDE.md`). The **Custom Code** key `extra_head` and the `ios_app_url` badge link are the keys in the settings handler's `CLEARABLE_SETTINGS` set, so saving an empty textarea/field removes them (the default loop skips empty values).
The seed block in `database.py` is guarded by `if "site_settings" in tables:` - on a brand-new DB the table does not exist yet (dataset creates tables lazily on first insert), so none of these rows are written until the table exists. Correct runtime behavior therefore relies on every consumer passing the production default to `get_setting`/`get_int_setting`, not on the seed.
@@ -240,3 +241,5 @@ Operational settings - read sites and rules:
| `registration_open` | `auth.py` `signup_page` (GET) and `signup` (POST) | POST returns before any DB write when closed |
**Booleans are `<select>`, never checkboxes.** The settings save handler (`admin.py`) skips empty form values so empty fields don't clobber existing rows. An unchecked checkbox submits nothing, so it could never be turned off - `registration_open` and `maintenance_mode` use `<option value="1">`/`<option value="0">` so a value is always submitted.
+5
View File
@@ -89,6 +89,11 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
if not poll:
return "/feed"
return resolve_object_url("post", poll.get("post_uid", ""))
if target_type == "battle":
war = get_table("opinion_wars").find_one(uid=target_uid)
if not war:
return "/battles"
return resolve_object_url("post", war.get("post_uid", ""))
if target_type == "workspace":
instance = get_table("instances").find_one(uid=target_uid)
return f"/admin/containers/{instance['uid']}" if instance else "/admin/containers"
+3
View File
@@ -17,6 +17,7 @@ REPORTABLE_TARGETS: dict[str, str] = {
"message": "messages",
"quiz": "quizzes",
"poll": "polls",
"battle": "opinion_wars",
"award": "awards",
"user": "users",
"issue": "issue_tickets",
@@ -48,6 +49,8 @@ UNREPORTABLE_TABLES: dict[str, str] = {
"bookmarks": "private to the owner",
"follows": "relationship rows, carry no authored content",
"poll_votes": "private ballots",
"opinion_war_fighters": "membership and damage counters, carry no authored content",
"opinion_war_events": "server-composed battle log rows, not authored content",
"quiz_attempts": "private to the participant",
"quiz_answers": "private to the participant",
"sessions": "authentication state",
+1
View File
@@ -19,6 +19,7 @@ NOTIFICATION_TYPES = [
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
{"key": "battle", "label": "Opinion Wars", "description": "Lead changes, results and fight-ready alerts for battles you joined"},
{"key": "workspace", "label": "Dev workspaces", "description": "Idle, quota, retention and moderation notices for your dev workspaces"},
{"key": "moderation", "label": "Moderation", "description": "Acknowledgements for reports you file and decisions taken on your content"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
+18
View File
@@ -150,6 +150,16 @@ def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str)
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
if target_type == "post" and "opinion_wars" in db.tables:
for uid in uids:
for war in db["opinion_wars"].find(post_uid=uid, deleted_at=None):
soft_delete(
"opinion_war_fighters", deleted_by, stamp=stamp, war_uid=war["uid"]
)
soft_delete(
"opinion_war_events", deleted_by, stamp=stamp, war_uid=war["uid"]
)
soft_delete("opinion_wars", deleted_by, stamp=stamp, post_uid=uid)
def delete_engagement(target_type: str, target_uids: list) -> None:
@@ -181,6 +191,14 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
if "poll_options" in tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)
if target_type == "post" and "opinion_wars" in tables:
for uid in uids:
for war in db["opinion_wars"].find(post_uid=uid):
if "opinion_war_fighters" in tables:
db["opinion_war_fighters"].delete(war_uid=war["uid"])
if "opinion_war_events" in tables:
db["opinion_war_events"].delete(war_uid=war["uid"])
db["opinion_wars"].delete(post_uid=uid)
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
+94
View File
@@ -1580,6 +1580,98 @@ def init_db():
)
_index(db, "quiz_answers", "idx_quiz_answers_quiz", ["quiz_uid"])
opinion_wars = get_table("opinion_wars")
for column, example in (
("uid", ""),
("post_uid", ""),
("user_uid", ""),
("faction_a", ""),
("faction_b", ""),
("hp_a", 0),
("hp_b", 0),
("leader", ""),
("status", "active"),
("winner", ""),
("created_at", ""),
("ends_at", ""),
("resolved_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not opinion_wars.has_column(column):
opinion_wars.create_column_by_example(column, example)
_index(db, "opinion_wars", "idx_opinion_wars_post", ["post_uid"], unique=True)
_index(db, "opinion_wars", "idx_opinion_wars_status_ends", ["status", "ends_at"])
_index(
db,
"opinion_wars",
"idx_opinion_wars_live_created",
["created_at"],
where="deleted_at IS NULL",
)
opinion_war_fighters = get_table("opinion_war_fighters")
for column, example in (
("uid", ""),
("war_uid", ""),
("user_uid", ""),
("faction", ""),
("hp_a", 0),
("hp_b", 0),
("fight_count", 0),
("last_fight_at", ""),
("cooldown_notified_at", ""),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not opinion_war_fighters.has_column(column):
opinion_war_fighters.create_column_by_example(column, example)
_index(
db,
"opinion_war_fighters",
"idx_opinion_war_fighters_war_user",
["war_uid", "user_uid"],
unique=True,
)
_index(
db,
"opinion_war_fighters",
"idx_opinion_war_fighters_war_faction",
["war_uid", "faction"],
)
_index(
db,
"opinion_war_fighters",
"idx_opinion_war_fighters_last_fight",
["last_fight_at"],
)
opinion_war_events = get_table("opinion_war_events")
for column, example in (
("uid", ""),
("war_uid", ""),
("seq", 0),
("kind", ""),
("message", ""),
("payload", ""),
("actor_uid", ""),
("created_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not opinion_war_events.has_column(column):
opinion_war_events.create_column_by_example(column, example)
_index(
db,
"opinion_war_events",
"idx_opinion_war_events_war_seq",
["war_uid", "seq"],
unique=True,
)
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
db,
@@ -1743,6 +1835,7 @@ def init_db():
"statistics_tracking_enabled": "1",
"docs_search_mode": "agent",
"outbound_proxy_url": "",
"ios_app_url": "https://apps.apple.com/app/devplace/id6797215143",
"devii_lessons_max_per_owner": "500",
"devii_lessons_max_age_days": "90",
"moderation_sla_hours": "24",
@@ -2142,3 +2235,4 @@ def _backfill_gamification():
f"{len(checked)} with milestone-eligible activity"
)
+3
View File
@@ -51,6 +51,9 @@ SOFT_DELETE_TABLES = [
"quiz_options",
"quiz_attempts",
"quiz_answers",
"opinion_wars",
"opinion_war_fighters",
"opinion_war_events",
"content_reports",
"moderation_actions",
"content_maturity",
+2
View File
@@ -22,6 +22,7 @@ from . import (
admin,
game,
quizzes,
battles,
)
ORDERED_GROUPS = [
@@ -46,4 +47,5 @@ ORDERED_GROUPS = [
admin.GROUP,
game.GROUP,
quizzes.GROUP,
battles.GROUP,
]
+149
View File
@@ -0,0 +1,149 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.opinionwar import rules
from .._shared import endpoint, field
FILTER_KEYS = ["active", "ended", "mine"]
SAMPLE_WAR = {
"uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
"post_uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
"post_url": "/posts/8bbb000000000003-tabs-or-spaces",
"post_title": "Tabs or spaces?",
"faction_a": "Tabs",
"faction_b": "Spaces",
"hp_a": 12548,
"hp_b": 7362,
"pct_a": 63,
"pct_b": 37,
"leader": "a",
"fighter_count": 42,
"status": "active",
"winner": "",
"ends_at": "2026-08-27T12:00:00+00:00",
"ends_in": "2d 14h 32m",
"last_seq": 87,
"fight_cost": rules.FIGHT_COST_COINS,
"top_contributors": [
{"username": "code_warrior", "faction": "a", "hp": 982},
],
"recent_events": [
{"seq": 87, "kind": "fight", "message": "code_warrior dealt 300 HP for Tabs", "faction": "a"},
],
"viewer": {
"faction": "a",
"hp": 256,
"rank": 7,
"can_fight": True,
"next_fight_at": "",
},
}
GROUP = {
"slug": "battles",
"title": "Opinion Wars",
"intro": f"""
# Opinion Wars
An Opinion War is a week-long two-faction battle attached to a post. The creator names
exactly two factions when creating the post (the `war_faction_a` / `war_faction_b` fields
on `POST /posts/create`); from that moment the battle runs for exactly
{rules.WAR_DURATION_DAYS} days.
Any signed-in member joins one of the two factions and may **fight** once every
{rules.FIGHT_COOLDOWN_HOURS} hours per battle. A fight costs {rules.FIGHT_COST_COINS}
Code Farm coins and deals deterministic, level-weighted damage for the fighter's faction:
`{rules.BASE_DAMAGE} + {rules.LEVEL_DAMAGE_STEP} * min(level, {rules.LEVEL_DAMAGE_CAP})`
HP, so a level 1 member deals {rules.damage_for(1)} HP and the bonus caps at
{rules.damage_for(rules.LEVEL_DAMAGE_CAP)} HP. There is no randomness. Switching factions
is allowed at any time; damage already dealt stays with the faction it was dealt to.
When the week is over the faction with more HP wins. Resolution is evaluated lazily on
read (no background clock): the first read after the deadline freezes the totals, awards
XP (participation for every fighter with at least one fight, a bonus for the winning
side, a bonus for the single top damage dealer) and notifies every fighter. Equal totals
are a draw with participation XP only.
Every battle keeps an ordered event log (kinds `join`, `switch`, `fight`, `lead_change`,
`result`) replayable with the `after` cursor; live frames are also published on the
pub/sub topic `public.battle.{{uid}}`.
All endpoints negotiate HTML or JSON. POST bodies are form encoded. Action POSTs answer
`{{"ok": true, "redirect": "...", "data": {{...}}}}`; a refused action (cooldown, missing
coins, ended battle) answers `400` as `{{"error": {{"status": 400, "message": "..."}}}}`.
""",
"endpoints": [
endpoint(
id="battles-list",
method="GET",
path="/battles",
title="Battle listing",
summary="Opinion Wars with HP totals, filter counts and the viewer's faction state.",
auth="public",
negotiation=True,
params=[
field("search", "query", "string", False, "tabs", "Match a faction name or the creator's username."),
field("filter", "query", "enum", False, "active", "Which battles to list.", options=FILTER_KEYS),
field("page", "query", "integer", False, "1", "1-based page number."),
],
sample_response={"battles": [SAMPLE_WAR], "counts": {"active": 3, "ended": 12, "mine": 1}},
),
endpoint(
id="battles-get",
method="GET",
path="/battles/{uid}",
title="Battle state",
summary="One battle's full serialized state, resolving it first when its week is over.",
auth="public",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
],
sample_response=SAMPLE_WAR,
),
endpoint(
id="battles-events",
method="GET",
path="/battles/{uid}/events",
title="Battle events",
summary="The ordered battle event log, replayable incrementally with the after cursor.",
auth="public",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
field("after", "query", "integer", False, "0", "Return only events with a seq greater than this."),
field("limit", "query", "integer", False, "500", "Maximum events to return."),
],
sample_response={"events": SAMPLE_WAR["recent_events"], "status": "active"},
),
endpoint(
id="battles-join",
method="POST",
path="/battles/{uid}/join",
title="Join or switch faction",
summary="Join faction a or b, or switch an existing fighter to the other side.",
auth="user",
encoding="form",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
field("faction", "form", "enum", True, "a", "Which side to join or switch to.", options=["a", "b"]),
],
sample_response={"ok": True, "redirect": SAMPLE_WAR["post_url"], "data": {"war": SAMPLE_WAR}},
),
endpoint(
id="battles-fight",
method="POST",
path="/battles/{uid}/fight",
title="Fight",
summary=(
f"Spend {rules.FIGHT_COST_COINS} Code Farm coins and deal level-weighted HP damage "
f"for your faction. Once per {rules.FIGHT_COOLDOWN_HOURS} hours per battle."
),
auth="user",
encoding="form",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
],
sample_response={"ok": True, "redirect": SAMPLE_WAR["post_url"], "data": {"war": SAMPLE_WAR, "damage": 300}},
),
],
}
+16
View File
@@ -117,6 +117,22 @@ four ways to sign requests.
"",
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
),
field(
"war_faction_a",
"form",
"string",
False,
"",
"Optional Opinion War faction A name (max 30 chars). Both faction names start the week-long battle.",
),
field(
"war_faction_b",
"form",
"string",
False,
"",
"Optional Opinion War faction B name (max 30 chars). Must differ from faction A.",
),
],
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
),
+4
View File
@@ -49,6 +49,7 @@ from devplacepy.utils import get_current_user, time_ago, safe_next, client_ip
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import (
auth,
battles,
feed,
posts,
comments,
@@ -106,6 +107,7 @@ from devplacepy.services.backup import BackupService
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.pubsub import PubSubService
from devplacepy.services.notification_relay import NotificationRelayService
from devplacepy.services.opinionwar.service import OpinionWarService
from devplacepy.services.live_view_relay import LiveViewRelayService
from devplacepy.services.presence_relay import PresenceRelayService
from devplacepy.services import presence
@@ -272,6 +274,7 @@ async def lifespan(app: FastAPI):
service_manager.register(DbApiJobService())
service_manager.register(PubSubService())
service_manager.register(NotificationRelayService())
service_manager.register(OpinionWarService())
service_manager.register(LiveViewRelayService())
service_manager.register(PresenceRelayService())
service_manager.register(DeepsearchService())
@@ -504,6 +507,7 @@ app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
app.include_router(quizzes.router, prefix="/quizzes")
app.include_router(battles.router, prefix="/battles")
app.include_router(workspaces.router, prefix="/workspaces")
+15
View File
@@ -176,6 +176,8 @@ class PostForm(BaseModel):
attachment_uids: list[str] = []
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
war_faction_a: str = Field(default="", max_length=30)
war_faction_b: str = Field(default="", max_length=30)
@field_validator("poll_options")
@classmethod
@@ -198,6 +200,17 @@ class PostForm(BaseModel):
return normalize_poll_options(value)
class WarJoinForm(BaseModel):
faction: str
@field_validator("faction")
@classmethod
def valid_faction(cls, value):
if value not in ("a", "b"):
raise ValueError("Faction must be a or b")
return value
class PostEditForm(BaseModel):
content: str = Field(min_length=10, max_length=125000)
title: str = Field(default="", max_length=500)
@@ -657,6 +670,7 @@ class AdminSettingsForm(BaseModel):
site_description: str = Field(default="", max_length=500)
site_tagline: str = Field(default="", max_length=500)
site_url: str = Field(default="", max_length=300)
ios_app_url: str = Field(default="", max_length=300)
max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10)
@@ -1119,3 +1133,4 @@ class MaturePreferenceForm(BaseModel):
class AccountDeleteForm(BaseModel):
password: str = Field(min_length=1, max_length=128)
confirm_text: str = Field(default="", max_length=40)
+1
View File
@@ -46,6 +46,7 @@ Prefixes are wired in `main.py`:
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
| `/battles` | battles.py - **Opinion Wars**, week-long two-faction battles attached to posts: `GET ""` (listing, quizzes-style filters active/ended/mine + search), `GET /{uid}` (JSON-only full state, runs lazy resolution), `GET /{uid}/events?after=` (durable event trail replay), `POST /{uid}/join` (join or switch faction), `POST /{uid}/fight` (spend 25 Code Farm coins, deal level-weighted damage, 24h cooldown). The battle card renders on the parent post (no battle detail HTML page). See `devplacepy/services/opinionwar/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
+2 -1
View File
@@ -16,7 +16,7 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
CLEARABLE_SETTINGS = {"extra_head"}
CLEARABLE_SETTINGS = {"extra_head", "ios_app_url"}
@router.get("/settings", response_class=HTMLResponse)
async def admin_settings(request: Request):
@@ -85,3 +85,4 @@ async def admin_settings_save(
links=[audit.setting(key)],
)
return action_result(request, "/admin/settings")
+129
View File
@@ -0,0 +1,129 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.database import resolve_object_url
from devplacepy.dependencies import json_or_form
from devplacepy.models import WarJoinForm
from devplacepy.responses import action_result, json_error, respond, wants_json
from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut
from devplacepy.seo import base_seo_context
from devplacepy.services.opinionwar import WarError, rules, store
from devplacepy.utils import get_current_user, not_found, require_user
router = APIRouter()
def _war_error(request: Request, message: str, redirect_url: str):
if wants_json(request):
return json_error(400, message)
separator = "&" if "?" in redirect_url else "?"
return RedirectResponse(
url=f"{redirect_url}{separator}error={quote(message)}", status_code=302
)
def _post_url(war: dict) -> str:
return resolve_object_url("post", war["post_uid"])
@router.get("", response_class=HTMLResponse)
async def battles_page(
request: Request, filter: str = "active", search: str = "", page: int = 1
):
user = get_current_user(request)
current_filter = filter if filter in store.FILTERS else "active"
battles, pagination = store.list_wars(
viewer=user,
war_filter=current_filter,
search=search,
page=max(1, page),
)
seo_ctx = base_seo_context(
request,
title="Opinion Wars",
description=(
"Week-long faction battles between developers. Pick a side, fight once "
"a day and carry your faction to victory."
),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Battles", "url": "/battles"},
],
)
return respond(
request,
"battles.html",
{
**seo_ctx,
"user": user,
"battles": battles,
"current_filter": current_filter,
"counts": store.filter_counts(user, search),
"search": search,
"pagination": pagination,
},
model=BattlesOut,
)
@router.get("/{war_uid}")
async def battle_state(request: Request, war_uid: str):
user = get_current_user(request)
serialized = store.get_war_serialized(store.get_war(war_uid), user)
if not serialized:
raise not_found("Battle not found")
return JSONResponse(WarOut.model_validate(serialized).model_dump())
@router.get("/{war_uid}/events")
async def battle_events(
request: Request, war_uid: str, after: int = 0, limit: int = rules.EVENT_LIMIT_DEFAULT
):
war = store.resolve_if_due(store.get_war(war_uid))
if not war:
raise not_found("Battle not found")
events = store.events_for(war_uid, after_seq=after, limit=limit)
return JSONResponse(
WarEventsOut.model_validate(
{"events": events, "status": war.get("status") or "active"}
).model_dump()
)
@router.post("/{war_uid}/join")
async def join_battle(
request: Request,
war_uid: str,
data: Annotated[WarJoinForm, Depends(json_or_form(WarJoinForm))],
):
user = require_user(request)
war = store.get_war(war_uid)
if not war:
raise not_found("Battle not found")
url = _post_url(war)
try:
war = store.join_war(war, user, data.faction, request)
except WarError as exc:
return _war_error(request, str(exc), url)
serialized = store.get_war_serialized(war, user)
return action_result(request, url, data={"war": serialized})
@router.post("/{war_uid}/fight")
async def fight_battle(request: Request, war_uid: str):
user = require_user(request)
war = store.get_war(war_uid)
if not war:
raise not_found("Battle not found")
url = _post_url(war)
try:
war, damage = store.fight(war, user, request)
except WarError as exc:
return _war_error(request, str(exc), url)
serialized = store.get_war_serialized(war, user)
return action_result(request, url, data={"war": serialized, "damage": damage})
+3 -1
View File
@@ -189,7 +189,9 @@ async def clippy_proxy(request: Request):
"Content-Type": "application/json",
"X-App-Reference": "devplace-devii-v-1-0-0",
}
if cfg.get("devii_ai_key"):
if user.get("api_key"):
headers["Authorization"] = f"Bearer {user['api_key']}"
elif cfg.get("devii_ai_key"):
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
async with stealth.stealth_async_client(timeout=45.0) as client:
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)
+6
View File
@@ -82,6 +82,12 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "opinion-wars",
"title": "Opinion Wars",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "block-and-mute",
"title": "Block and mute",
+5
View File
@@ -14,6 +14,9 @@ from devplacepy.database import (
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
)
from devplacepy.services.opinionwar import store as war_store
from devplacepy.database import (
paginate_diverse,
text_search_clause,
)
@@ -101,6 +104,7 @@ async def feed_page(
get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids_list, user)
wars_map = war_store.get_wars_by_post_uids(post_uids_list, user)
for item in posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
@@ -108,6 +112,7 @@ async def feed_page(
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
item["war"] = wars_map.get(uid)
seo_ctx = list_page_seo(
request,
+2
View File
@@ -41,6 +41,7 @@ from devplacepy.seo import (
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
from devplacepy.services.audit import record as audit
from devplacepy.services.opinionwar import store as war_store
from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
@@ -87,6 +88,7 @@ async def create_post(request: Request, data: Annotated[PostForm, Depends(json_o
)
create_poll(uid, user, data.poll_question, data.poll_options, request)
war_store.create_war(uid, user, data.war_faction_a, data.war_faction_b, request)
url = f"/posts/{post_slug}"
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
+5
View File
@@ -17,6 +17,9 @@ from devplacepy.database import (
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
)
from devplacepy.services.opinionwar import store as war_store
from devplacepy.database import (
get_activity_heatmap,
get_activity_months,
get_streaks,
@@ -203,11 +206,13 @@ async def profile_page(
else set()
)
polls_map = get_polls_by_post_uids(post_uids, current_user)
wars_map = war_store.get_wars_by_post_uids(post_uids, current_user)
for item in posts:
uid = item["post"]["uid"]
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
item["war"] = wars_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
for b in badges:
+5
View File
@@ -23,6 +23,9 @@ from devplacepy.database import (
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
)
from devplacepy.services.opinionwar import store as war_store
from devplacepy.database import (
paginate,
text_search_clause,
resolve_by_slug,
@@ -289,12 +292,14 @@ async def project_detail(request: Request, project_slug: str, before: str = None
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids, user)
wars_map = war_store.get_wars_by_post_uids(post_uids, user)
for item in devlog_posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
item["war"] = wars_map.get(uid)
return respond(
request,
+9
View File
@@ -151,6 +151,15 @@ from devplacepy.schemas.dbapi import (
DbTableOut,
NlQueryOut,
)
from devplacepy.schemas.battles import (
BattlesOut,
WarContributorOut,
WarEventOut,
WarEventsOut,
WarOut,
WarPersonOut,
WarViewerOut,
)
from devplacepy.schemas.quiz import (
QuizAnswerOut,
QuizAnswerResultOut,
+82
View File
@@ -0,0 +1,82 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out
class WarPersonOut(_Out):
uid: str = ""
username: str = ""
avatar_seed: Optional[str] = None
level: int = 1
class WarContributorOut(WarPersonOut):
faction: str = ""
hp: int = 0
class WarEventOut(_Out):
seq: int = 0
kind: str = ""
message: str = ""
faction: str = ""
created_at: str = ""
hp_a: Optional[int] = None
hp_b: Optional[int] = None
damage: Optional[int] = None
winner: Optional[str] = None
class WarViewerOut(_Out):
faction: str = ""
hp: int = 0
rank: int = 0
fight_count: int = 0
last_fight_at: str = ""
next_fight_at: str = ""
can_fight: bool = False
class WarOut(_Out):
uid: str = ""
post_uid: str = ""
post_url: str = ""
post_title: str = ""
author: Optional[WarPersonOut] = None
faction_a: str = ""
faction_b: str = ""
hp_a: int = 0
hp_b: int = 0
pct_a: int = 50
pct_b: int = 50
leader: str = ""
fighter_count: int = 0
status: str = "active"
winner: str = ""
winner_label: str = ""
created_at: str = ""
ends_at: str = ""
ends_in: str = ""
resolved_at: str = ""
last_seq: int = 0
fight_cost: int = 0
top_contributors: list[WarContributorOut] = []
recent_events: list[WarEventOut] = []
viewer: Optional[WarViewerOut] = None
class BattlesOut(_Out):
battles: list[WarOut] = []
current_filter: str = "active"
counts: dict = {}
search: str = ""
pagination: dict = {}
class WarEventsOut(_Out):
events: list[WarEventOut] = []
status: str = ""
+3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.battles import WarOut
from devplacepy.schemas.content import (
AttachmentOut,
CommentItemOut,
@@ -33,6 +34,7 @@ class FeedItemOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
war: Optional[WarOut] = None
project_link: Optional[ProjectLinkOut] = None
@@ -134,6 +136,7 @@ class PostDetailOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
war: Optional[WarOut] = None
comment_count: Optional[int] = None
related_posts: list[FeedItemOut] = []
topics: list[str] = []
+1
View File
@@ -426,6 +426,7 @@ def _build_sitemap(base_url):
)
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/quizzes", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/battles", changefreq="daily", priority="0.7"))
urlset.append(
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
)
+1 -1
View File
@@ -2,7 +2,7 @@ This file documents the audit log subsystem. Claude Code auto-loads it when a fi
## Audit log (`services/audit/`)
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 288 keys across 42 domains.
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 293 keys across 43 domains.
### Package layout
+1
View File
@@ -24,6 +24,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"reaction": "engagement",
"bookmark": "engagement",
"poll": "engagement",
"battle": "engagement",
"project": "project",
"file": "project_files",
"dir": "project_files",
@@ -5,6 +5,7 @@ from __future__ import annotations
from ..spec import Action, Catalog
from .admin import ADMIN_ACTIONS
from .auth import AUTH_ACTIONS
from .battles import BATTLE_ACTIONS
from .comments import COMMENTS_ACTIONS
from .dbapi import DBAPI_ACTIONS
from .engagement import ENGAGEMENT_ACTIONS
@@ -48,6 +49,7 @@ ACTIONS: tuple[Action, ...] = (
+ GATEWAY_ACTIONS
+ GAME_ACTIONS
+ QUIZ_ACTIONS
+ BATTLE_ACTIONS
+ MODERATION_ACTIONS
)
@@ -0,0 +1,85 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import body, confirm, path, query
BATTLE_ACTIONS: tuple[Action, ...] = (
Action(
name="list_battles",
method="GET",
path="/battles",
summary="List Opinion War battles",
description=(
"Returns week-long two-faction battles attached to posts, with HP totals, "
"percentages, top contributors and the viewer's own faction state."
),
handler="http",
requires_auth=False,
read_only=True,
params=(
query("search", "Match a faction name or the creator's username."),
query("filter", "One of active, ended, mine. Defaults to active."),
query("page", "1-based page number."),
),
),
Action(
name="get_battle",
method="GET",
path="/battles/{uid}",
summary="Get one Opinion War battle's full state",
handler="http",
requires_auth=False,
read_only=True,
params=(path("uid", "Battle uid."),),
),
Action(
name="battle_events",
method="GET",
path="/battles/{uid}/events",
summary="Read a battle's event log",
description=(
"Ordered battle events (join, switch, fight, lead_change, result). Pass "
"after to replay incrementally from a sequence number."
),
handler="http",
requires_auth=False,
read_only=True,
params=(
path("uid", "Battle uid."),
query("after", "Return only events with a seq greater than this."),
query("limit", "Maximum events to return."),
),
),
Action(
name="join_battle",
method="POST",
path="/battles/{uid}/join",
summary="Join or switch to a faction in an Opinion War",
description=(
"Joins faction a or b, or switches an existing fighter to the other side. "
"Damage already dealt stays with the faction it was dealt to."
),
handler="http",
requires_auth=True,
params=(
path("uid", "Battle uid."),
body("faction", "Which side to join or switch to: a or b.", required=True),
confirm(),
),
),
Action(
name="fight_battle",
method="POST",
path="/battles/{uid}/fight",
summary="Fight for your faction in an Opinion War",
description=(
"Spends 25 Code Farm coins and deals level-weighted HP damage for the "
"fighter's faction. Allowed once per 24 hours per battle."
),
handler="http",
requires_auth=True,
params=(path("uid", "Battle uid."), confirm()),
),
)
@@ -36,6 +36,14 @@ POSTS_ACTIONS: tuple[Action, ...] = (
"poll_options",
"Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
),
body(
"war_faction_a",
"Optional Opinion War faction A name (max 30 chars). Both faction names are required to start the week-long battle.",
),
body(
"war_faction_b",
"Optional Opinion War faction B name (max 30 chars). Must differ from faction A.",
),
),
),
Action(
@@ -32,6 +32,8 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {
"join_battle",
"fight_battle",
"delete_my_account",
"decide_report",
"suspend_user",
+134
View File
@@ -0,0 +1,134 @@
# Opinion Wars (`devplacepy/services/opinionwar/`, `devplacepy/routers/battles.py`)
This file documents the Opinion War subsystem. Claude Code auto-loads it when a file under
`devplacepy/services/opinionwar/` is read or edited.
## Overview
An Opinion War is a week-long two-faction battle attached to a post, structurally a sibling of
polls: one `opinion_wars` row keyed by `post_uid`, created inside `POST /posts/create` when both
`war_faction_a` and `war_faction_b` are submitted (the composer's "Start Opinion War" builder,
same disabled-inputs opt-in as the poll builder, driven by `static/js/WarComposer.js`). Malformed
faction input (blank, equal casefolded, over 30 chars, or a second war on the same post) silently
creates no war - the `create_poll` precedent. Wars are create-time only: there is deliberately NO
war builder in the post edit modal, because `ends_at` anchors to `created_at + 7 days` and a war
attached later would either be born expired or break that anchor.
Members join faction `a` or `b`, may switch any time (damage already dealt STAYS with the faction
it was dealt to - fighters carry per-side `hp_a`/`hp_b` columns so the sum invariant survives
defection), and fight once per 24h per war. A fight costs `FIGHT_COST_COINS` (25) Code Farm coins
and deals `damage_for(level)` = `100 + 10 * min(level, 20)` HP - deterministic by design (no RNG:
purchasable randomness is an app-store conditional the platform must never trigger). After 7 days
the faction with more HP wins; equal totals are a draw.
## Package layout
```
services/opinionwar/
rules.py pure constants and formulas: damage_for, ends_at_for, is_ended, cooldown math,
leader_of/winner_of, pct_split, ends_in_label. No DB, no network.
store.py all DB access: create/join/fight/resolve, the atomic transitions, event
allocation, batch serialization, the /battles listing queries.
service.py OpinionWarService (BaseService): pub/sub relay + resolution/cooldown backstop.
```
`store.py` is the only place that touches the tables. `rules.py` never imports `store`. The
router (`routers/battles.py`) never issues SQL.
## Tables
`opinion_wars` (uid, post_uid UNIQUE, user_uid, faction_a/b, hp_a/b, leader, status
active|resolved, winner a|b|draw, created_at, ends_at, resolved_at), `opinion_war_fighters`
(war_uid+user_uid UNIQUE, faction, hp_a/b = damage dealt FOR each side, fight_count,
last_fight_at, cooldown_notified_at), `opinion_war_events` (war_uid+seq UNIQUE, kind, message,
payload JSON, actor_uid). All three are in `SOFT_DELETE_TABLES` with born-live inserts; columns +
indexes ensured in `init_db()`. Moderation classification: `battle` -> `opinion_wars` in
`REPORTABLE_TARGETS` (creator-authored faction names), fighters/events in `UNREPORTABLE_TABLES`.
`resolve_object_url("battle", uid)` resolves to the parent post URL. Post deletion cascades
soft+hard through `database/ranking.py` under the shared stamp.
## Atomicity: every transition is a conditional UPDATE
All mutations go through `database/atomic.py::conditional_update_row` (which writes `updated_at`
in every statement - the wars and fighters tables carry the column for this reason; the events
table is insert-only and does not).
| Transition | Precondition | rowcount 0 means |
|---|---|---|
| Claim the cooldown | `COALESCE(last_fight_at,'')='' OR last_fight_at <= :cutoff` | fought within 24h - refuse, money untouched |
| Spend the coins | `COALESCE(coins,0) >= :cost` (`conditional_update_farm`) | broke - restore the cooldown, refuse |
| Land the damage | `status='active' AND ends_at > :now` | war ended mid-flight - refund coins, restore cooldown, resolve |
| Switch faction | `faction != :faction` | already on that side - no event |
| Claim a lead flip | `COALESCE(leader,'') != :leader AND status='active'` | someone else owns the flip - no duplicate event/notifications |
| Resolve | `status='active'` (winner computed IN the statement via CASE) | already resolved - no second XP, no second result event |
| Cooldown notification | `cooldown_notified_at != last_fight_at AND last_fight_at = :last` | already notified for this fight - never repeats, re-arms on the next fight |
**The fight sequence is cooldown-first with compensation, a deliberate deviation from the game's
charge-then-claim:** a crash mid-sequence must cost the user one turn, never coins. Claim the
cooldown, then spend, then deal damage; each later failure compensates the earlier steps
(cooldown restore is itself a CAS guarded on the exact value just written, so a concurrent
legitimate fight is never clobbered). Accepted residual: a process crash between the cooldown
stamp and the damage write loses one turn and self-heals in 24h.
**Event seq allocation is one atomic statement** (`allocate_event`): a raw
`INSERT ... SELECT COALESCE(MAX(seq),0)+1 ... WHERE war_uid = :w` inside `with db:` (raw writes
do not auto-commit). The MAX deliberately scans ALL rows including soft-deleted so seq stays
monotonic across a trash restore. This differs from isslop, whose seq counter lives in the single
worker process - war events originate in request handlers on any worker.
## Resolution is lazy, exactly-once, no cron
`resolve_if_due(war)` runs at every read and mutation entry (the batch card loader, the listing,
`GET /battles/{uid}`, events, join, fight). The CAS winner (rowcount 1) alone awards XP via
`award_rewards` (participation `XP_BATTLE_PART` for every fighter with `fight_count > 0`, winners
add `XP_BATTLE_WIN` + `track_action("battle_win")`, the single top damage dealer war-wide adds
`XP_BATTLE_TOP`; a draw pays participation only), emits the `result` event, defers the result
notifications to ALL joined fighters (zero-fight members get the notification, no XP), and
records `battle.resolve` via `audit.record_system`. Race-proven: 8 concurrent resolvers award
exactly once.
## Live events: relay on the lock owner, DB trail is the source of truth
Request handlers NEVER call `services.pubsub.publish` - it is in-process and WS subscribers
converge on the service-lock-owner worker, so a handler-side publish on the other worker would
silently drop the frame. `OpinionWarService` (2s tick, registered in `main.py` beside
`NotificationRelayService`) relays: watermark on `opinion_war_events.id`, publish each new row to
`public.battle.{war_uid}`. The same tick runs a 60s-throttled sweep resolving ended wars nobody
viewed and sending the fight-ready notifications. Clients (`dp-opinion-war`,
`static/js/components/AppOpinionWar.js`) pair the subscription with a 15s `Poller` on
`GET /battles/{uid}/events?after={lastSeq}` and dedupe by seq, so guests, reconnects and
disabled-services test runs all replay from the durable trail.
## Notifications
Type `battle` in `database.NOTIFICATION_TYPES`. Three messages: lead change (to that war's
fighters, actor excluded, actor as `related_uid` so block/mute applies), the result (to all
fighters, recipient-self as `related_uid`), and "Your Fight is ready" (the service sweep, marker
CAS above). All `target_url`s are the post URL, so `view_post`'s existing
`mark_notifications_read_by_target` clears them on view with zero new code.
## The card and the listing
`templates/_opinion_war.html` (local `_war`) renders wherever `_post_card.html`/`post.html`
render, attached by `store.get_wars_by_post_uids` at the four poll-map call sites (feed, profile
posts tab, project devlog, `content.load_detail`). The battlefield strip is the self-contained
`_opinion_war_field.html` + the `.war-field*` layer in `static/css/opinionwar.css`: CSS pixel-art
sprites (castles, soldiers, campfire) drawn with the box-shadow pixel technique on a `--px` unit,
animated with `steps()` keyframes, fully disabled under `prefers-reduced-motion`. The stylesheet
is global in `base.html` (the `engagement.css` precedent - the card renders across many pages)
and opens with the file-scoped `--war-*` palette block (the `isslop.css` pattern). Faction names
are user content: always rendered through `render_title`.
`GET /battles` is the listing (quizzes-pattern: page pagination, active/ended/mine filters,
`_sidebar_search.html`); there is NO battle detail HTML page - `GET /battles/{uid}` is JSON-only
and the post page is the surface. `serialize_war` is the single card/JSON shape (`WarOut`).
## Verification
The feature is a spendable resource + bounded state machine + multi-path read-then-write, so the
root CLAUDE.md four-layer procedure applies to any change here: property checks on `rules.py`
across the full domain, stateful fuzz asserting `war.hp_x == SUM(fighters.hp_x)` and
`coins >= 0` after every action, and REAL multi-process races (concurrent fight = exactly one
lands and exactly one fee; two wars vs 1.5x the fee = exactly one lands; concurrent resolve =
XP once; concurrent join = one fighter row). Persisted tests: `tests/unit/services/opinionwar/`,
`tests/api/battles/`, `tests/api/posts/create.py` (war creation), `tests/e2e/battles/`.
@@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from . import rules, store
from .store import WarError
__all__ = ["rules", "store", "WarError"]
+111
View File
@@ -0,0 +1,111 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta, timezone
WAR_DURATION_DAYS = 7
FIGHT_COST_COINS = 25
FIGHT_COOLDOWN_HOURS = 24
BASE_DAMAGE = 100
LEVEL_DAMAGE_STEP = 10
LEVEL_DAMAGE_CAP = 20
FACTION_NAME_MAX = 30
TOP_CONTRIBUTORS = 3
EVENT_TICKER_LIMIT = 5
EVENT_LIMIT_DEFAULT = 500
EVENT_KINDS = ("join", "switch", "fight", "lead_change", "result")
def _parse(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _now(now: datetime | None = None) -> datetime:
return now or datetime.now(timezone.utc)
def damage_for(level) -> int:
try:
value = int(level or 1)
except (TypeError, ValueError):
value = 1
return BASE_DAMAGE + LEVEL_DAMAGE_STEP * min(max(value, 1), LEVEL_DAMAGE_CAP)
def ends_at_for(created_at: str) -> str:
start = _parse(created_at) or _now()
return (start + timedelta(days=WAR_DURATION_DAYS)).isoformat()
def is_ended(ends_at: str, now: datetime | None = None) -> bool:
ends = _parse(ends_at)
if not ends:
return False
return _now(now) >= ends
def cooldown_cutoff(now: datetime | None = None) -> str:
return (_now(now) - timedelta(hours=FIGHT_COOLDOWN_HOURS)).isoformat()
def cooldown_ready_at(last_fight_at: str) -> str:
last = _parse(last_fight_at)
if not last:
return ""
return (last + timedelta(hours=FIGHT_COOLDOWN_HOURS)).isoformat()
def can_fight_at(last_fight_at: str, now: datetime | None = None) -> bool:
ready = _parse(cooldown_ready_at(last_fight_at))
if not ready:
return True
return _now(now) >= ready
def leader_of(hp_a: int, hp_b: int) -> str:
if hp_a > hp_b:
return "a"
if hp_b > hp_a:
return "b"
return ""
def winner_of(hp_a: int, hp_b: int) -> str:
return leader_of(hp_a, hp_b) or "draw"
def pct_split(hp_a: int, hp_b: int) -> tuple[int, int]:
left = max(0, int(hp_a or 0))
right = max(0, int(hp_b or 0))
total = left + right
if not total:
return 50, 50
pct_a = round(left * 100 / total)
return pct_a, 100 - pct_a
def ends_in_label(ends_at: str, now: datetime | None = None) -> str:
ends = _parse(ends_at)
if not ends:
return ""
remaining = (ends - _now(now)).total_seconds()
if remaining <= 0:
return ""
minutes = int(remaining // 60)
days, minutes = divmod(minutes, 1440)
hours, minutes = divmod(minutes, 60)
if days:
return f"{days}d {hours}h {minutes}m"
if hours:
return f"{hours}h {minutes}m"
return f"{max(minutes, 1)}m"
+90
View File
@@ -0,0 +1,90 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import time
from devplacepy.database import db
from devplacepy.services.base import BaseService
from devplacepy.services.pubsub import publish as pubsub_publish
from . import store
BATCH_LIMIT = 500
SWEEP_SECONDS = 60
def battle_topic(war_uid: str) -> str:
return f"public.battle.{war_uid}"
class OpinionWarService(BaseService):
title = "Opinion Wars"
description = (
"Relays persisted battle events onto the pub/sub bus for live cards, resolves "
"ended wars that nobody viewed, and sends the fight-cooldown-ready "
"notifications. Runs on the service lock owner, where every pub/sub "
"subscriber converges; the event table stays the source of truth."
)
default_enabled = True
def __init__(self):
super().__init__(name="opinionwar", interval_seconds=2)
self._watermark = 0
self._primed = False
self._last_sweep = 0.0
def _max_id(self) -> int:
if "opinion_war_events" not in db.tables:
return 0
rows = list(db.query("SELECT MAX(id) AS max_id FROM opinion_war_events"))
value = rows[0]["max_id"] if rows else None
return int(value or 0)
async def _relay_events(self) -> None:
if "opinion_war_events" not in db.tables:
return
if not self._primed:
self._watermark = self._max_id()
self._primed = True
return
rows = list(
db.query(
"SELECT * FROM opinion_war_events WHERE id > :wm "
"ORDER BY id ASC LIMIT :lim",
wm=self._watermark,
lim=BATCH_LIMIT,
)
)
if not rows:
return
delivered = 0
for row in rows:
delivered += await pubsub_publish(
battle_topic(row["war_uid"]), store._event_dict(dict(row))
)
self._watermark = max(row["id"] for row in rows)
if delivered:
self.log(f"relayed {len(rows)} battle event(s), {delivered} live frame(s)")
def _sweep(self) -> None:
now = time.monotonic()
if now - self._last_sweep < SWEEP_SECONDS:
return
self._last_sweep = now
resolved = store.resolve_due_wars()
if resolved:
self.log(f"resolved {resolved} ended war(s)")
notified = 0
for fighter in store.cooldown_ready_fighters():
if store.notify_cooldown_ready(fighter):
notified += 1
if notified:
self.log(f"sent {notified} fight-ready notification(s)")
async def run_once(self) -> None:
await self._relay_events()
self._sweep()
def collect_metrics(self) -> dict:
return {"watermark": self._watermark, "primed": self._primed}
+794
View File
@@ -0,0 +1,794 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from devplacepy import config
from devplacepy.database import (
_in_clause,
build_pagination,
conditional_update_row,
db,
get_blocked_uids,
get_table,
get_users_by_uids,
resolve_object_url,
text_search_clause,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.background import background
from devplacepy.services.game.store import (
conditional_update_farm,
ensure_farm,
refund_farm,
)
from devplacepy.utils import create_notification, generate_uid, track_action
from devplacepy.utils.rewards import (
XP_BATTLE_PART,
XP_BATTLE_TOP,
XP_BATTLE_WIN,
award_rewards,
)
from . import rules
FILTERS = ("active", "ended", "mine")
class WarError(Exception):
pass
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime | None = None) -> str:
return (value or _now()).isoformat()
def _wars():
return get_table("opinion_wars")
def _fighters():
return get_table("opinion_war_fighters")
def _events():
return get_table("opinion_war_events")
def born_live(fields: dict) -> dict:
stamp = _iso()
return {
**fields,
"created_at": fields.get("created_at") or stamp,
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
}
def faction_label(war: dict, faction: str) -> str:
return war.get("faction_a") if faction == "a" else war.get("faction_b")
def get_war(war_uid: str) -> dict | None:
if not war_uid:
return None
return _wars().find_one(uid=war_uid, deleted_at=None)
def get_war_for_post(post_uid: str) -> dict | None:
if not post_uid:
return None
return _wars().find_one(post_uid=post_uid, deleted_at=None)
def create_war(
post_uid: str, user: dict, faction_a: str, faction_b: str, request=None
) -> str | None:
faction_a = (faction_a or "").strip()
faction_b = (faction_b or "").strip()
if not faction_a or not faction_b:
return None
if len(faction_a) > rules.FACTION_NAME_MAX or len(faction_b) > rules.FACTION_NAME_MAX:
return None
if faction_a.casefold() == faction_b.casefold():
return None
if get_war_for_post(post_uid):
return None
uid = generate_uid()
created_at = _iso()
_wars().insert(
born_live(
{
"uid": uid,
"post_uid": post_uid,
"user_uid": user["uid"],
"faction_a": faction_a,
"faction_b": faction_b,
"hp_a": 0,
"hp_b": 0,
"leader": "",
"status": "active",
"winner": "",
"created_at": created_at,
"ends_at": rules.ends_at_for(created_at),
"resolved_at": "",
}
)
)
label = f"{faction_a} vs {faction_b}"
audit.record(
request,
"battle.create",
user=user,
target_type="battle",
target_uid=uid,
target_label=label,
metadata={"faction_a": faction_a, "faction_b": faction_b},
summary=f"{user['username']} started Opinion War {label}",
links=[audit.target("battle", uid, label), audit.parent("post", post_uid)],
)
track_action(user["uid"], "battle_create")
return uid
def join_war(war: dict, user: dict, faction: str, request=None) -> dict:
war = resolve_if_due(war)
if not war or war.get("status") != "active":
raise WarError("This battle has ended.")
if faction not in ("a", "b"):
raise WarError("Pick faction a or b.")
label = faction_label(war, faction)
fighter = _fighters().find_one(war_uid=war["uid"], user_uid=user["uid"])
if fighter and fighter.get("deleted_at"):
_fighters().update(
{
"id": fighter["id"],
"faction": faction,
"deleted_at": None,
"deleted_by": None,
"updated_at": _iso(),
},
["id"],
)
kind = "join"
elif fighter:
rows = conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
"faction = :faction",
"faction != :faction AND deleted_at IS NULL",
{"faction": faction},
)
if not rows:
return war
kind = "switch"
else:
try:
_fighters().insert(
born_live(
{
"uid": generate_uid(),
"war_uid": war["uid"],
"user_uid": user["uid"],
"faction": faction,
"hp_a": 0,
"hp_b": 0,
"fight_count": 0,
"last_fight_at": "",
"cooldown_notified_at": "",
}
)
)
except IntegrityError:
return join_war(get_war(war["uid"]) or war, user, faction, request)
kind = "join"
verb = "defected to" if kind == "switch" else "joined"
allocate_event(
war["uid"],
kind,
f"{user['username']} {verb} {label}",
{"faction": faction},
user["uid"],
)
audit.record(
request,
f"battle.{kind}",
user=user,
target_type="battle",
target_uid=war["uid"],
target_label=label,
metadata={"faction": faction},
summary=f"{user['username']} {verb} {label}",
links=[audit.target("battle", war["uid"], label), audit.parent("post", war["post_uid"])],
)
return get_war(war["uid"]) or war
def fight(war: dict, user: dict, request=None) -> tuple[dict, int]:
war = resolve_if_due(war)
if not war or war.get("status") != "active":
raise WarError("This battle has ended.")
fighter = _fighters().find_one(
war_uid=war["uid"], user_uid=user["uid"], deleted_at=None
)
if not fighter:
raise WarError("Join a faction first.")
now = _iso()
previous = fighter.get("last_fight_at") or ""
claimed = conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
"last_fight_at = :now",
"(COALESCE(last_fight_at, '') = '' OR last_fight_at <= :cutoff) AND deleted_at IS NULL",
{"now": now, "cutoff": rules.cooldown_cutoff()},
)
if not claimed:
raise WarError("You can fight once every 24 hours.")
farm = ensure_farm(user["uid"])
spent = conditional_update_farm(
farm["uid"],
"coins = COALESCE(coins, 0) - :cost",
"COALESCE(coins, 0) >= :cost",
{"cost": rules.FIGHT_COST_COINS},
)
if not spent:
_restore_cooldown(fighter["uid"], previous, now)
raise WarError(
f"A fight costs {rules.FIGHT_COST_COINS} Code Farm coins - earn some on your farm first."
)
faction = "a" if fighter.get("faction") != "b" else "b"
side = "hp_a" if faction == "a" else "hp_b"
damage = rules.damage_for(user.get("level"))
landed = conditional_update_row(
"opinion_wars",
war["uid"],
f"{side} = COALESCE({side}, 0) + :damage",
"status = 'active' AND ends_at > :now AND deleted_at IS NULL",
{"damage": damage, "now": now},
)
if not landed:
refund_farm(farm["uid"], rules.FIGHT_COST_COINS)
_restore_cooldown(fighter["uid"], previous, now)
resolve_if_due(get_war(war["uid"]))
raise WarError("The battle ended before your attack landed.")
conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
f"{side} = COALESCE({side}, 0) + :damage, fight_count = COALESCE(fight_count, 0) + 1",
"1 = 1",
{"damage": damage},
)
label = faction_label(war, faction)
fresh = get_war(war["uid"]) or war
allocate_event(
war["uid"],
"fight",
f"{user['username']} dealt {damage} HP for {label}",
{
"faction": faction,
"damage": damage,
"hp_a": int(fresh.get("hp_a") or 0),
"hp_b": int(fresh.get("hp_b") or 0),
},
user["uid"],
)
_claim_lead(fresh, user)
track_action(user["uid"], "battle_fight")
audit.record(
request,
"battle.fight",
user=user,
target_type="battle",
target_uid=war["uid"],
target_label=label,
metadata={"faction": faction, "damage": damage, "cost": rules.FIGHT_COST_COINS},
summary=f"{user['username']} dealt {damage} HP for {label}",
links=[audit.target("battle", war["uid"], label), audit.parent("post", war["post_uid"])],
)
return get_war(war["uid"]) or fresh, damage
def _restore_cooldown(fighter_uid: str, previous: str, claimed: str) -> None:
conditional_update_row(
"opinion_war_fighters",
fighter_uid,
"last_fight_at = :previous",
"last_fight_at = :claimed",
{"previous": previous, "claimed": claimed},
)
def _claim_lead(war: dict, user: dict) -> None:
new_leader = rules.leader_of(int(war.get("hp_a") or 0), int(war.get("hp_b") or 0))
if not new_leader:
return
rows = conditional_update_row(
"opinion_wars",
war["uid"],
"leader = :leader",
"COALESCE(leader, '') != :leader AND status = 'active'",
{"leader": new_leader},
)
if not rows:
return
label = faction_label(war, new_leader)
allocate_event(
war["uid"],
"lead_change",
f"{label} took the lead!",
{
"faction": new_leader,
"hp_a": int(war.get("hp_a") or 0),
"hp_b": int(war.get("hp_b") or 0),
},
user["uid"],
)
background.submit(
_notify_fighters,
war["uid"],
f"{label} took the lead in an Opinion War you fight in",
user["uid"],
user["uid"],
)
def _notify_fighters(
war_uid: str, message: str, related_uid: str | None, exclude_uid: str | None
) -> None:
war = get_war(war_uid)
if not war:
return
url = resolve_object_url("post", war["post_uid"])
for row in _fighters().find(war_uid=war_uid, deleted_at=None):
recipient = row.get("user_uid") or ""
if not recipient or recipient == exclude_uid:
continue
create_notification(
recipient, "battle", message, related_uid or recipient, target_url=url
)
def allocate_event(
war_uid: str, kind: str, message: str, payload: dict | None = None, actor_uid: str = ""
) -> dict:
uid = generate_uid()
created_at = _iso()
with db:
db.query(
"INSERT INTO opinion_war_events "
"(uid, war_uid, seq, kind, message, payload, actor_uid, created_at, deleted_at, deleted_by) "
"SELECT :uid, :war_uid, COALESCE(MAX(seq), 0) + 1, :kind, :message, :payload, "
":actor_uid, :created_at, NULL, NULL "
"FROM opinion_war_events WHERE war_uid = :war_uid",
uid=uid,
war_uid=war_uid,
kind=kind,
message=message,
payload=json.dumps(payload or {}, ensure_ascii=False),
actor_uid=actor_uid or "",
created_at=created_at,
)
row = _events().find_one(uid=uid)
return _event_dict(row) if row else {
"seq": 0,
"kind": kind,
"message": message,
"faction": (payload or {}).get("faction", ""),
"created_at": created_at,
}
def _event_dict(row: dict) -> dict:
try:
payload = json.loads(row.get("payload") or "{}")
except (TypeError, ValueError):
payload = {}
if not isinstance(payload, dict):
payload = {}
event = {
"seq": int(row.get("seq") or 0),
"kind": row.get("kind") or "",
"message": row.get("message") or "",
"faction": payload.get("faction", ""),
"created_at": row.get("created_at") or "",
}
for key in ("hp_a", "hp_b", "damage", "winner"):
if key in payload:
event[key] = payload[key]
return event
def events_for(war_uid: str, after_seq: int = 0, limit: int = rules.EVENT_LIMIT_DEFAULT) -> list[dict]:
capped = max(1, min(int(limit or 1), rules.EVENT_LIMIT_DEFAULT))
rows = _events().find(
war_uid=war_uid,
seq={">": max(0, int(after_seq or 0))},
deleted_at=None,
order_by=["seq"],
_limit=capped,
)
return [_event_dict(dict(row)) for row in rows]
def resolve_if_due(war: dict | None) -> dict | None:
if not war or war.get("status") != "active":
return war
if not rules.is_ended(war.get("ends_at") or ""):
return war
return resolve_war(war)
def resolve_war(war: dict) -> dict:
rows = conditional_update_row(
"opinion_wars",
war["uid"],
"status = 'resolved', resolved_at = :now, "
"winner = CASE WHEN COALESCE(hp_a, 0) > COALESCE(hp_b, 0) THEN 'a' "
"WHEN COALESCE(hp_b, 0) > COALESCE(hp_a, 0) THEN 'b' ELSE 'draw' END",
"status = 'active' AND deleted_at IS NULL",
{"now": _iso()},
)
resolved = get_war(war["uid"]) or war
if rows:
_award_and_announce(resolved)
return resolved
def _award_and_announce(war: dict) -> None:
winner = war.get("winner") or "draw"
hp_a = int(war.get("hp_a") or 0)
hp_b = int(war.get("hp_b") or 0)
fighters = list(_fighters().find(war_uid=war["uid"], deleted_at=None))
active = [row for row in fighters if int(row.get("fight_count") or 0) > 0]
for row in active:
award_rewards(row["user_uid"], XP_BATTLE_PART)
if winner in ("a", "b") and row.get("faction") == winner:
award_rewards(row["user_uid"], XP_BATTLE_WIN)
track_action(row["user_uid"], "battle_win")
if winner in ("a", "b") and active:
top = min(
active,
key=lambda row: (
-(int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0)),
int(row.get("id") or 0),
),
)
award_rewards(top["user_uid"], XP_BATTLE_TOP)
if winner == "a":
message = f"Battle over: {war['faction_a']} wins {hp_a} to {hp_b}"
elif winner == "b":
message = f"Battle over: {war['faction_b']} wins {hp_b} to {hp_a}"
else:
message = f"Battle over: a draw at {hp_a} HP each"
allocate_event(
war["uid"], "result", message, {"winner": winner, "hp_a": hp_a, "hp_b": hp_b}, ""
)
background.submit(_notify_fighters, war["uid"], message, None, None)
audit.record_system(
"battle.resolve",
target_type="battle",
target_uid=war["uid"],
target_label=f"{war['faction_a']} vs {war['faction_b']}",
metadata={"winner": winner, "hp_a": hp_a, "hp_b": hp_b},
summary=message,
links=[
audit.target("battle", war["uid"], f"{war['faction_a']} vs {war['faction_b']}"),
audit.parent("post", war["post_uid"]),
],
)
def resolve_due_wars(limit: int = 25) -> int:
if "opinion_wars" not in db.tables:
return 0
due = list(
_wars().find(
status="active",
ends_at={"<=": _iso()},
deleted_at=None,
order_by=["ends_at"],
_limit=max(1, limit),
)
)
resolved = 0
for war in due:
if resolve_war(dict(war)).get("status") == "resolved":
resolved += 1
return resolved
def cooldown_ready_fighters(limit: int = 50) -> list[dict]:
if "opinion_war_fighters" not in db.tables:
return []
rows = db.query(
"SELECT f.* FROM opinion_war_fighters f "
"JOIN opinion_wars w ON w.uid = f.war_uid "
"WHERE f.deleted_at IS NULL AND w.deleted_at IS NULL AND w.status = 'active' "
"AND COALESCE(f.last_fight_at, '') != '' AND f.last_fight_at <= :cutoff "
"AND COALESCE(f.cooldown_notified_at, '') != f.last_fight_at "
"LIMIT :limit",
cutoff=rules.cooldown_cutoff(),
limit=max(1, limit),
)
return [dict(row) for row in rows]
def notify_cooldown_ready(fighter: dict) -> bool:
last = fighter.get("last_fight_at") or ""
rows = conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
"cooldown_notified_at = :last",
"COALESCE(cooldown_notified_at, '') != :last AND last_fight_at = :last",
{"last": last},
)
if not rows:
return False
war = get_war(fighter.get("war_uid") or "")
if not war:
return True
create_notification(
fighter["user_uid"],
"battle",
"Your Fight is ready - return to the battle",
fighter["user_uid"],
target_url=resolve_object_url("post", war["post_uid"]),
)
return True
def get_wars_by_post_uids(post_uids: list, user: dict | None = None) -> dict:
uids = [uid for uid in (post_uids or []) if uid]
if not uids or "opinion_wars" not in db.tables:
return {}
placeholders, params = _in_clause(uids)
wars = [
dict(row)
for row in db.query(
f"SELECT * FROM opinion_wars WHERE post_uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
)
]
if not wars:
return {}
serialized = serialize_wars(wars, user)
return {item["post_uid"]: item for item in serialized}
def get_war_serialized(war: dict | None, user: dict | None = None) -> dict | None:
if not war:
return None
items = serialize_wars([dict(war)], user)
return items[0] if items else None
def get_war_serialized_for_post(post_uid: str, user: dict | None = None) -> dict | None:
return get_war_serialized(get_war_for_post(post_uid), user)
def serialize_wars(wars: list[dict], user: dict | None = None) -> list[dict]:
wars = [resolve_if_due(war) for war in wars]
war_uids = [war["uid"] for war in wars]
placeholders, params = _in_clause(war_uids)
fighters_by_war: dict[str, list[dict]] = {uid: [] for uid in war_uids}
if "opinion_war_fighters" in db.tables:
for row in db.query(
f"SELECT * FROM opinion_war_fighters WHERE war_uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
):
fighters_by_war.setdefault(row["war_uid"], []).append(dict(row))
events_by_war: dict[str, list[dict]] = {uid: [] for uid in war_uids}
if "opinion_war_events" in db.tables:
for row in db.query(
"SELECT * FROM ("
"SELECT e.*, ROW_NUMBER() OVER (PARTITION BY war_uid ORDER BY seq DESC) AS rn "
f"FROM opinion_war_events e WHERE war_uid IN ({placeholders}) "
"AND deleted_at IS NULL) WHERE rn <= :ticker",
**params,
ticker=rules.EVENT_TICKER_LIMIT,
):
events_by_war.setdefault(row["war_uid"], []).append(dict(row))
posts_by_uid: dict[str, dict] = {}
post_placeholders, post_params = _in_clause([war["post_uid"] for war in wars])
if "posts" in db.tables:
for row in db.query(
f"SELECT uid, slug, title FROM posts WHERE uid IN ({post_placeholders}) "
"AND deleted_at IS NULL",
**post_params,
):
posts_by_uid[row["uid"]] = dict(row)
user_uids = {war["user_uid"] for war in wars}
for rows in fighters_by_war.values():
ranked = _ranked(rows)
for fighter in ranked[: rules.TOP_CONTRIBUTORS]:
user_uids.add(fighter["user_uid"])
users = get_users_by_uids(list(user_uids))
return [
_serialize_war(
war,
_ranked(fighters_by_war.get(war["uid"], [])),
events_by_war.get(war["uid"], []),
posts_by_uid.get(war["post_uid"]),
users,
user,
)
for war in wars
]
def _ranked(fighters: list[dict]) -> list[dict]:
return sorted(
fighters,
key=lambda row: (
-(int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0)),
int(row.get("id") or 0),
),
)
def _person(users: dict, uid: str) -> dict:
row = users.get(uid) or {}
return {
"uid": uid,
"username": row.get("username") or "unknown",
"avatar_seed": row.get("avatar_seed"),
"level": int(row.get("level") or 1),
}
def _serialize_war(
war: dict,
ranked: list[dict],
event_rows: list[dict],
post: dict | None,
users: dict,
viewer: dict | None,
) -> dict:
hp_a = int(war.get("hp_a") or 0)
hp_b = int(war.get("hp_b") or 0)
pct_a, pct_b = rules.pct_split(hp_a, hp_b)
events = sorted((_event_dict(row) for row in event_rows), key=lambda e: -e["seq"])
last_seq = events[0]["seq"] if events else 0
status = war.get("status") or "active"
winner = war.get("winner") or ""
viewer_row = None
viewer_uid = (viewer or {}).get("uid") or ""
if viewer_uid:
for index, row in enumerate(ranked):
if row.get("user_uid") == viewer_uid:
viewer_row = {
"faction": row.get("faction") or "",
"hp": int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0),
"rank": index + 1,
"fight_count": int(row.get("fight_count") or 0),
"last_fight_at": row.get("last_fight_at") or "",
"next_fight_at": rules.cooldown_ready_at(row.get("last_fight_at") or ""),
"can_fight": status == "active"
and rules.can_fight_at(row.get("last_fight_at") or ""),
}
break
top_contributors = [
{
**_person(users, row["user_uid"]),
"faction": row.get("faction") or "",
"hp": int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0),
}
for row in ranked[: rules.TOP_CONTRIBUTORS]
if int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0) > 0
]
slug = (post or {}).get("slug") or war["post_uid"]
return {
"uid": war["uid"],
"post_uid": war["post_uid"],
"post_url": f"/posts/{slug}",
"post_title": (post or {}).get("title") or "",
"author": _person(users, war["user_uid"]),
"faction_a": war.get("faction_a") or "",
"faction_b": war.get("faction_b") or "",
"hp_a": hp_a,
"hp_b": hp_b,
"pct_a": pct_a,
"pct_b": pct_b,
"leader": war.get("leader") or "",
"fighter_count": len(ranked),
"status": status,
"winner": winner,
"winner_label": faction_label(war, winner) or "" if winner in ("a", "b") else "",
"created_at": war.get("created_at") or "",
"ends_at": war.get("ends_at") or "",
"ends_in": rules.ends_in_label(war.get("ends_at") or ""),
"resolved_at": war.get("resolved_at") or "",
"last_seq": last_seq,
"fight_cost": rules.FIGHT_COST_COINS,
"top_contributors": top_contributors,
"recent_events": events,
"viewer": viewer_row,
}
def list_wars(
*,
viewer: dict | None = None,
war_filter: str = "active",
search: str = "",
page: int = 1,
per_page: int = config.BATTLES_LIST_PER_PAGE,
) -> tuple[list[dict], dict]:
table = _wars()
resolve_due_wars()
clauses, filters = _list_clauses(table, viewer, war_filter, search)
if clauses is None:
return [], build_pagination(1, 0, per_page)
total = table.count(*clauses, deleted_at=None, **filters)
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
table.find(
*clauses,
deleted_at=None,
order_by=["-created_at"],
_limit=pagination["per_page"],
_offset=offset,
**filters,
)
)
return serialize_wars([dict(row) for row in rows], viewer), pagination
def filter_counts(viewer: dict | None, search: str = "") -> dict[str, int]:
table = _wars()
counts: dict[str, int] = {}
for name in FILTERS:
clauses, filters = _list_clauses(table, viewer, name, search)
counts[name] = (
0 if clauses is None else table.count(*clauses, deleted_at=None, **filters)
)
return counts
def _joined_war_uids(viewer_uid: str) -> list[str]:
return [
row["war_uid"]
for row in _fighters().find(user_uid=viewer_uid, deleted_at=None)
]
def _list_clauses(table, viewer, war_filter, search):
if not table.exists:
return None, {}
viewer_uid = (viewer or {}).get("uid", "")
columns = table.table.columns
clauses = []
filters: dict = {}
if war_filter == "ended":
filters["status"] = "resolved"
elif war_filter == "mine":
if not viewer_uid:
return None, {}
joined = _joined_war_uids(viewer_uid)
own = columns.user_uid == viewer_uid
clauses.append(or_(own, columns.uid.in_(joined)) if joined else own)
else:
filters["status"] = "active"
if viewer_uid and war_filter != "mine":
blocked = get_blocked_uids(viewer_uid)
if blocked:
clauses.append(columns.user_uid.notin_(blocked))
match = text_search_clause(
table, search, ("faction_a", "faction_b"), author_field="user_uid"
)
if match is not None:
clauses.append(match)
return clauses, filters
+47
View File
@@ -0,0 +1,47 @@
<!-- retoor <retoor@molodetz.nl> -->
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+17
View File
@@ -29,6 +29,23 @@
max-height: 200px;
}
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb) {
width: 100%;
max-width: 100%;
}
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb):hover {
transform: none;
border-color: var(--border-light);
}
.attachment-gallery.single .gallery-thumb {
width: 100%;
max-width: 100%;
max-height: 480px;
object-fit: contain;
}
.attachment-gallery-item:has(.non-image) {
width: 120px;
height: 120px;
+33 -2
View File
@@ -717,6 +717,17 @@ img {
line-height: 1;
}
.app-store-nav-link {
display: inline-flex;
align-items: center;
line-height: 0;
}
.app-store-nav-badge {
width: 90px;
height: auto;
}
.topnav-mobile-overlay {
display: none;
position: fixed;
@@ -771,6 +782,15 @@ img {
background: var(--accent-light);
}
.app-store-mobile-link {
justify-content: flex-start;
}
.app-store-mobile-badge {
width: 120px;
height: auto;
}
.topnav-mobile-divider {
height: 1px;
background: var(--border);
@@ -1128,6 +1148,17 @@ body:has(.page-messages) {
color: var(--text-primary);
}
.app-store-link {
display: inline-flex;
align-items: center;
line-height: 0;
}
.app-store-badge {
width: 120px;
height: auto;
}
.sr-only {
position: absolute;
width: 1px;
@@ -1176,7 +1207,8 @@ body:has(.page-messages) {
}
.topnav-tools-dropdown,
.topnav-icon[href="/leaderboard"] {
.topnav-icon[href="/leaderboard"],
.app-store-nav-link {
display: none;
}
@@ -1392,4 +1424,3 @@ body:has(.page-messages) {
color: var(--text-secondary);
margin: 0 0 var(--space-lg);
}
+714
View File
@@ -0,0 +1,714 @@
:root {
--war-a-rgb: 66, 133, 244;
--war-a: rgb(var(--war-a-rgb));
--war-b-rgb: 234, 67, 53;
--war-b: rgb(var(--war-b-rgb));
--war-stone: #9b93ad;
--war-stone-dark: #5b5372;
--war-window: #ffd66b;
--war-door: #241b31;
--war-skin: #e8b98c;
--war-steel: #cfd6e4;
--war-flame: #ffb02e;
--war-flame-hot: #ff5a1f;
--war-wood: #7a4a2b;
--war-ground-top: #35502f;
--war-ground-deep: #22371f;
--war-sky-top: #120c26;
--war-sky-deep: #322357;
--war-leg: #2c2440;
--war-gold: #f4c542;
}
dp-opinion-war {
display: block;
}
.war-card {
position: relative;
z-index: 1;
margin: var(--space-md) 0;
padding: var(--space-md);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
}
.war-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.war-head-title {
font-family: var(--font-mono);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent-light);
}
.war-countdown {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.war-bars {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.war-side {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.war-side-b {
text-align: right;
}
.war-side-name {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.war-side-a .war-side-name {
color: var(--war-a);
}
.war-side-b .war-side-name {
color: var(--war-b);
}
.war-vs {
font-family: var(--font-mono);
font-weight: 700;
font-size: 1.1rem;
color: var(--war-gold);
}
.war-bar {
position: relative;
height: 20px;
border: 1px solid var(--border-light);
border-radius: 4px;
background: var(--bg-input);
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.war-bar-fill {
position: absolute;
inset: 0;
width: var(--war-pct, 0%);
transition: width 0.4s ease;
}
.war-bar-a .war-bar-fill {
background: rgba(var(--war-a-rgb), 0.55);
}
.war-bar-b .war-bar-fill {
left: auto;
right: 0;
background: rgba(var(--war-b-rgb), 0.55);
}
.war-bar-pct {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: var(--text-primary);
z-index: 1;
}
.war-bar-a .war-bar-pct {
left: var(--space-sm);
}
.war-bar-b .war-bar-pct {
right: var(--space-sm);
}
.war-side-hp {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.war-field {
--px: 3px;
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-sm);
height: 96px;
padding: 0 var(--space-md) 12px;
border-radius: var(--radius);
background: linear-gradient(var(--war-sky-top), var(--war-sky-deep));
overflow: hidden;
margin-bottom: var(--space-md);
}
.war-ground {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 10px;
background: linear-gradient(var(--war-ground-top), var(--war-ground-deep));
}
.war-px {
position: absolute;
top: 0;
left: 0;
width: var(--px);
height: var(--px);
}
.war-castle {
position: relative;
z-index: 1;
width: calc(var(--px) * 5);
height: calc(var(--px) * 8);
flex-shrink: 0;
}
.war-castle-b {
transform: scaleX(-1);
}
.war-castle-px {
background: var(--war-stone-dark);
box-shadow:
calc(var(--px) * 2) 0 0 0 var(--war-stone-dark),
calc(var(--px) * 4) 0 0 0 var(--war-stone-dark),
0 var(--px) 0 0 var(--war-stone-dark),
var(--px) var(--px) 0 0 var(--war-stone-dark),
calc(var(--px) * 2) var(--px) 0 0 var(--war-stone-dark),
calc(var(--px) * 3) var(--px) 0 0 var(--war-stone-dark),
calc(var(--px) * 4) var(--px) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 2) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 2) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-stone),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 2) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 3) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 3) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--war-window),
calc(var(--px) * 3) calc(var(--px) * 3) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 3) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 4) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 4) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 4) 0 0 var(--war-stone),
calc(var(--px) * 3) calc(var(--px) * 4) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 4) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 5) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 5) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 5) 0 0 var(--war-stone),
calc(var(--px) * 3) calc(var(--px) * 5) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 5) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 6) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 6) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 6) 0 0 var(--war-door),
calc(var(--px) * 3) calc(var(--px) * 6) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 6) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 7) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 7) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 7) 0 0 var(--war-door),
calc(var(--px) * 3) calc(var(--px) * 7) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 7) 0 0 var(--war-stone-dark);
}
.war-flag {
top: calc(var(--px) * -4);
left: calc(var(--px) * 2);
background: var(--war-stone-dark);
box-shadow:
0 var(--px) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 2) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 3) 0 0 var(--war-stone-dark),
var(--px) 0 0 0 var(--war-flag-color),
calc(var(--px) * 2) 0 0 0 var(--war-flag-color),
var(--px) var(--px) 0 0 var(--war-flag-color);
}
.war-flag-a {
--war-flag-color: var(--war-a);
}
.war-flag-b {
--war-flag-color: var(--war-b);
}
.war-troops {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-sm);
flex-shrink: 1;
min-width: 0;
transition: transform 0.6s ease;
}
.war-field-lead-a .war-troops-a {
transform: translateX(calc(var(--px) * 2));
}
.war-field-lead-b .war-troops-b {
transform: translateX(calc(var(--px) * -2));
}
.war-soldier {
position: relative;
display: block;
width: calc(var(--px) * 6);
height: calc(var(--px) * 7);
animation: war-march 1.2s steps(1, end) infinite;
}
.war-soldier:nth-child(2) {
animation-delay: 0.4s;
}
.war-soldier:nth-child(3) {
animation-delay: 0.8s;
}
.war-soldier:nth-child(4) {
animation-delay: 0.2s;
}
.war-soldier-a {
--war-soldier-main: var(--war-a);
}
.war-soldier-b {
--war-soldier-main: var(--war-b);
transform: scaleX(-1);
}
.war-soldier-px {
background: transparent;
box-shadow:
var(--px) 0 0 0 var(--war-soldier-main),
calc(var(--px) * 2) 0 0 0 var(--war-soldier-main),
calc(var(--px) * 3) 0 0 0 var(--war-soldier-main),
var(--px) var(--px) 0 0 var(--war-skin),
calc(var(--px) * 2) var(--px) 0 0 var(--war-skin),
calc(var(--px) * 3) var(--px) 0 0 var(--war-skin),
calc(var(--px) * 5) var(--px) 0 0 var(--war-steel),
0 calc(var(--px) * 2) 0 0 var(--war-soldier-main),
var(--px) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 4) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 5) calc(var(--px) * 2) 0 0 var(--war-steel),
var(--px) calc(var(--px) * 3) 0 0 var(--war-soldier-main),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--war-soldier-main),
calc(var(--px) * 3) calc(var(--px) * 3) 0 0 var(--war-soldier-main),
calc(var(--px) * 4) calc(var(--px) * 3) 0 0 var(--war-skin),
calc(var(--px) * 5) calc(var(--px) * 3) 0 0 var(--war-steel),
var(--px) calc(var(--px) * 4) 0 0 var(--war-soldier-main),
calc(var(--px) * 2) calc(var(--px) * 4) 0 0 var(--war-soldier-main),
calc(var(--px) * 3) calc(var(--px) * 4) 0 0 var(--war-soldier-main),
var(--px) calc(var(--px) * 5) 0 0 var(--war-leg),
calc(var(--px) * 3) calc(var(--px) * 5) 0 0 var(--war-leg),
var(--px) calc(var(--px) * 6) 0 0 var(--war-leg),
calc(var(--px) * 3) calc(var(--px) * 6) 0 0 var(--war-leg);
}
.war-campfire {
position: relative;
z-index: 1;
width: calc(var(--px) * 5);
height: calc(var(--px) * 5);
flex-shrink: 0;
}
.war-fire-logs {
background: transparent;
box-shadow:
var(--px) calc(var(--px) * 3) 0 0 var(--war-wood),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--war-wood),
calc(var(--px) * 3) calc(var(--px) * 3) 0 0 var(--war-wood),
0 calc(var(--px) * 4) 0 0 var(--war-wood),
var(--px) calc(var(--px) * 4) 0 0 var(--war-wood),
calc(var(--px) * 2) calc(var(--px) * 4) 0 0 var(--war-wood),
calc(var(--px) * 3) calc(var(--px) * 4) 0 0 var(--war-wood),
calc(var(--px) * 4) calc(var(--px) * 4) 0 0 var(--war-wood);
}
.war-fire-flame {
background: transparent;
animation: war-flicker 0.8s steps(1, end) infinite;
box-shadow:
calc(var(--px) * 2) 0 0 0 var(--war-flame-hot),
var(--px) var(--px) 0 0 var(--war-flame),
calc(var(--px) * 3) var(--px) 0 0 var(--war-flame),
var(--px) calc(var(--px) * 2) 0 0 var(--war-flame),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-flame-hot),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-flame);
}
.war-victory {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
margin-bottom: var(--space-md);
border: 1px solid var(--war-gold);
border-radius: var(--radius);
background: rgba(var(--accent-rgb), 0.12);
}
.war-victory-title {
font-family: var(--font-mono);
font-weight: 700;
color: var(--war-gold);
}
.war-mine {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
}
.war-mine-faction {
font-weight: 700;
}
.war-tag-a {
color: var(--war-a);
}
.war-tag-b {
color: var(--war-b);
}
.war-mine-stats {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.war-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.war-join-btn.war-join-a {
border-color: rgba(var(--war-a-rgb), 0.6);
}
.war-join-btn.war-join-b {
border-color: rgba(var(--war-b-rgb), 0.6);
}
.war-fight-btn[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.war-block-label {
display: block;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-muted);
margin-bottom: var(--space-xs);
}
.war-contributors {
margin-bottom: var(--space-md);
}
.war-contributor-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.war-contributor {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
}
.war-contributor-hp {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.8rem;
}
.war-text-a {
color: var(--war-a);
}
.war-text-b {
color: var(--war-b);
}
.war-events {
margin-bottom: var(--space-md);
}
.war-ticker {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.war-event {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
font-size: 0.85rem;
min-width: 0;
}
.war-event-empty {
color: var(--text-muted);
}
.war-event-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
background: var(--text-muted);
}
.war-dot-a {
background: var(--war-a);
}
.war-dot-b {
background: var(--war-b);
}
.war-event-message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.war-event-time {
margin-left: auto;
flex-shrink: 0;
font-size: 0.72rem;
color: var(--text-muted);
}
.war-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.war-all-link {
font-weight: 600;
color: var(--accent-light);
}
.war-foot-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8rem;
}
.war-foot-btn:hover {
color: var(--text-primary);
background: var(--bg-card-hover);
}
@keyframes war-march {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(calc(var(--px) * -1));
}
}
@keyframes war-flicker {
0%,
100% {
box-shadow:
calc(var(--px) * 2) 0 0 0 var(--war-flame-hot),
var(--px) var(--px) 0 0 var(--war-flame),
calc(var(--px) * 3) var(--px) 0 0 var(--war-flame),
var(--px) calc(var(--px) * 2) 0 0 var(--war-flame),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-flame-hot),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-flame);
}
50% {
box-shadow:
var(--px) 0 0 0 var(--war-flame),
calc(var(--px) * 2) var(--px) 0 0 var(--war-flame-hot),
calc(var(--px) * 3) 0 0 0 var(--war-flame),
var(--px) calc(var(--px) * 2) 0 0 var(--war-flame-hot),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-flame),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-flame);
}
}
.war-soldier-b.war-soldier {
animation-name: war-march-b;
}
@keyframes war-march-b {
0%,
100% {
transform: scaleX(-1) translateY(0);
}
50% {
transform: scaleX(-1) translateY(calc(var(--px) * -1));
}
}
.war-filter-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
}
.war-listing-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.war-listing-meta {
font-size: 0.8rem;
color: var(--text-muted);
}
.war-builder {
margin: var(--space-sm) 0;
padding: var(--space-md);
border: 1px dashed var(--border-light);
border-radius: var(--radius);
}
.war-builder[hidden] {
display: none;
}
.war-builder-hint {
margin: 0 0 var(--space-sm);
font-size: 0.82rem;
color: var(--text-secondary);
}
.war-builder-row {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.war-builder-row input {
flex: 1;
min-width: 0;
}
.war-builder-vs {
font-family: var(--font-mono);
font-weight: 700;
color: var(--war-gold);
}
.war-builder-error {
margin: var(--space-sm) 0 0;
color: var(--danger);
font-size: 0.82rem;
}
@media (max-width: 768px) {
.war-field {
--px: 2px;
height: 76px;
}
}
@media (max-width: 480px) {
.war-bars {
gap: var(--space-sm);
}
.war-vs {
font-size: 0.9rem;
}
.war-bar-pct {
display: none;
}
.war-builder-row {
flex-direction: column;
align-items: stretch;
}
}
@media (prefers-reduced-motion: reduce) {
.war-soldier,
.war-fire-flame {
animation: none;
}
.war-bar-fill,
.war-troops {
transition: none;
}
}
+14
View File
@@ -681,6 +681,20 @@
margin-bottom: var(--space-xl);
}
.quiz-slide-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.quiz-slide-counter {
color: var(--text-secondary);
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.quiz-question-answered .quiz-answer-form {
opacity: 0.7;
}
+2
View File
@@ -15,6 +15,7 @@ import { CounterManager } from "./CounterManager.js";
import { ReactionBar } from "./ReactionBar.js";
import { BookmarkManager } from "./BookmarkManager.js";
import { PollManager } from "./PollManager.js";
import { WarComposer } from "./WarComposer.js";
import { ApiKeyManager } from "./ApiKeyManager.js";
import { AvatarRegenerator } from "./AvatarRegenerator.js";
import { CustomizationToggle } from "./CustomizationToggle.js";
@@ -87,6 +88,7 @@ class Application {
this.reactions = new ReactionBar();
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
this.warComposer = new WarComposer();
this.apiKey = new ApiKeyManager();
this.avatarRegenerator = new AvatarRegenerator();
this.customizationToggle = new CustomizationToggle();
+7 -1
View File
@@ -92,7 +92,11 @@ File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `
**Ingesting a file from a URL.** `store_attachment_from_url(url, user_uid, filename=None)` (async, in `attachments.py`) is the remote counterpart to `store_attachment`: it downloads the URL on the server through `fetch_remote_file()` - SSRF-guarded (`_guard_public_url` resolves the host and refuses private/loopback/reserved/multicast addresses, mirroring the Devii fetch guard) and size-capped (streams, aborting once `_get_max_upload_bytes()` is exceeded) - resolves a filename from the URL path or the response `Content-Type` (`MIME_TO_EXT`), then calls `store_attachment()` so the bytes land in the **exact same** pipeline (validation, thumbnailing, DB row). It raises `RemoteFetchError(message, status)` which the route maps to an HTTP status. It is exposed at `POST /uploads/upload-url` (`UploadUrlForm{url, filename?}`, `require_user_api`) and as the Devii catalog action `attach_url` (handler `http`, `requires_auth=True`); both return the same record as `/uploads/upload`. The returned `uid` binds to a resource the same way as any upload - via `attachment_uids` at create/edit time - so attaching a remote image is just `attach_url` then `create_post`/`create_project`/etc. with that uid. Do not re-download remote files in a router; reuse this helper so the guard and size cap stay in one place.
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once.
**Every caller MUST bind `attachments` before including the partial** - `{% set attachments = item.get('attachments', []) %}` or `{% with attachments=... %}`. The partial iterates the bare name `attachments`, so a caller that only guards on `{% if item.attachments %}` and includes without binding renders the gallery from whatever `attachments` happens to be in the surrounding page context. This is not theoretical: `_post_card.html` did exactly that, so **feed and profile cards silently rendered an empty gallery for every post that had an image**, and on a project page (where `project_detail.html` sets `attachments` at template scope for the project's own files) a devlog card would have rendered the *project's* attachments as if they were the post's. Guarded by `tests/e2e/feed.py::test_feed_card_shows_the_post_image`.
**A lone attachment is a hero, not a chip.** When the gallery holds exactly one item the partial adds a `single` class, and `attachments.css` widens that item to the full content column (`max-height: 480px`, `object-fit: contain`, no hover scale) instead of the 240x200 chip a multi-item gallery uses. **The `single` branch must serve `att['url']`, never `thumbnail_url`** - a thumbnail is 200px on its longest side, so blowing it up to the column width renders visibly blurry. That is the whole reason the src is a conditional rather than "thumbnail when one exists". Because the partial is shared, this applies everywhere at once: post cards, post detail, comments, gists, projects and chat bubbles. Animated GIFs never had a thumbnail to begin with (`THUMBNAIL_EXTENSIONS` excludes `.gif`, so animation survives), which means they already took the original-file path and simply render larger now. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
Media is served **inline** (not forced-download) for known-safe types only. The set `INLINE_MEDIA_EXTENSIONS` in `main.py` (`UploadStaticFiles`) and the matching `map $uri $upload_disposition` in `nginx/nginx.conf.template` must stay in sync: images/video/audio -> `inline` (so `<video>` plays and seeks via Range), everything else -> `attachment`. SVG is deliberately excluded from both (stored-XSS defense). `ContentRenderer.js` embeds direct video URLs typed into content via `videoExtRe`, mirroring its image handling.
@@ -104,6 +108,8 @@ Every file-upload UI is the one custom element `dp-upload` (`static/js/component
- `direct` (the project file browser, `project_files.html`): uploads to a custom `endpoint` with `field-name` plus a settable `extraFields` (e.g. `{path}`) and emits `dp-upload:uploaded` / `dp-upload:done` / `dp-upload:error`. `ProjectFiles.uploadTo(dir)` sets `extraFields` then calls `widget.open()`, and refreshes the tree on `done`.
- `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged.
**Paste-to-attach is the opt-in `paste` boolean attribute**, not a per-form handler. With it set, `dp-upload` binds ONE `paste` listener on its `closest("form")` and routes the clipboard's image files through the same `handleFiles` path as the picker and the drop target, so validation, limits, the terms gate and the hidden `attachment_uids` field are shared - never re-implement a clipboard reader in a page controller. It is opt-in because a form may hold several upload buttons (`projects.html` has cover + logo beside the attachment one) and a form-wide default would attach one pasted image to all of them; mark exactly the button that owns the form's content attachments. Set on `_attachment_form.html` (so every form including it - post composer, post edit, gists, projects, issues, screenshots - inherits it), `_comment_form.html`, `messages.html`, and the `mode="embed"` skeleton `AppChat` builds. Non-image clipboard payloads fall through untouched, so pasting text still types.
The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain.
## ReportDialog (`ReportDialog.js`, `app.reportDialog`)
+106
View File
@@ -0,0 +1,106 @@
// retoor <retoor@molodetz.nl>
export class WarComposer {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
document.addEventListener("submit", (event) => this.onSubmit(event), true);
}
onClick(event) {
const toggle = event.target.closest("[data-war-toggle]");
if (toggle) {
event.preventDefault();
this.toggleBuilder(toggle);
return;
}
const pollToggle = event.target.closest("[data-poll-toggle]");
if (pollToggle) {
this.closeWhenPollOpens(pollToggle);
}
}
toggleBuilder(toggle) {
const form = toggle.closest("form");
const builder = form ? form.querySelector("[data-war-builder]") : null;
if (!builder) {
return;
}
const activating = builder.hidden;
if (activating) {
this.closePollBuilder(form);
}
builder.hidden = !activating;
this.setWarEnabled(builder, activating);
const label = toggle.querySelector("[data-war-toggle-label]");
if (label) {
label.textContent = activating ? "Remove Opinion War" : "Start Opinion War";
}
}
closePollBuilder(form) {
const pollBuilder = form.querySelector("[data-poll-builder]");
const pollToggle = form.querySelector("[data-poll-toggle]");
if (pollBuilder && pollToggle && !pollBuilder.hidden) {
pollToggle.click();
}
}
closeWhenPollOpens(pollToggle) {
const form = pollToggle.closest("form");
const builder = form ? form.querySelector("[data-war-builder]") : null;
const toggle = form ? form.querySelector("[data-war-toggle]") : null;
if (!builder || !toggle || builder.hidden) {
return;
}
builder.hidden = true;
this.setWarEnabled(builder, false);
const label = toggle.querySelector("[data-war-toggle-label]");
if (label) {
label.textContent = "Start Opinion War";
}
}
setWarEnabled(builder, enabled) {
builder.querySelectorAll("input[name='war_faction_a'], input[name='war_faction_b']").forEach((input) => {
input.disabled = !enabled;
if (!enabled) {
input.value = "";
}
});
const error = builder.querySelector("[data-war-error]");
if (error) {
error.hidden = true;
error.textContent = "";
}
}
onSubmit(event) {
const form = event.target;
if (!form || typeof form.querySelector !== "function") {
return;
}
const builder = form.querySelector("[data-war-builder]");
if (!builder || builder.hidden) {
return;
}
const factionA = form.querySelector("input[name='war_faction_a']");
const factionB = form.querySelector("input[name='war_faction_b']");
const nameA = factionA ? factionA.value.trim() : "";
const nameB = factionB ? factionB.value.trim() : "";
let problem = "";
if (!nameA || !nameB) {
problem = "Name both factions, or remove the Opinion War.";
} else if (nameA.toLowerCase() === nameB.toLowerCase()) {
problem = "The two factions need different names.";
}
if (problem) {
event.preventDefault();
event.stopImmediatePropagation();
const error = builder.querySelector("[data-war-error]");
if (error) {
error.textContent = problem;
error.hidden = false;
}
}
}
}
@@ -199,6 +199,7 @@ export class AppChat extends Component {
this.upload = document.createElement("dp-upload");
this.upload.setAttribute("multiple", "");
this.upload.setAttribute("paste", "");
this.upload.setAttribute("max-files", String(this.maxAttachments));
this.sendBtn = document.createElement("button");
@@ -0,0 +1,254 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { Http } from "../Http.js";
import { Poller } from "../Poller.js";
const POLL_INTERVAL_MS = 15000;
const COUNTDOWN_INTERVAL_MS = 30000;
const TICKER_CAP = 5;
export class AppOpinionWar extends Component {
connectedCallback() {
this._uid = this.attr("uid");
this._topic = this.attr("topic");
this._lastSeq = this.intAttr("seq", 0);
this._endsAt = this.attr("ends-at");
this._status = this.attr("status", "active");
this._countdown = this.querySelector("[data-war-countdown]");
this._ticker = this.querySelector("[data-war-ticker]");
this._victory = this.querySelector("[data-war-victory]");
this._victoryTitle = this.querySelector("[data-war-victory-title]");
this._actions = this.querySelector("[data-war-actions]");
this._field = this.querySelector("[data-war-field]");
this._subscribed = false;
this._onSubmitBound = (event) => this._onSubmit(event);
this.addEventListener("submit", this._onSubmitBound);
if (this._status === "active") {
this._subscribe();
this._poller = new Poller(() => this._poll(), POLL_INTERVAL_MS, { immediate: false, pauseHidden: true });
this._timer = window.setInterval(() => this._tickCountdown(), COUNTDOWN_INTERVAL_MS);
this._tickCountdown();
}
}
disconnectedCallback() {
this._teardown();
this.removeEventListener("submit", this._onSubmitBound);
}
_teardown() {
if (this._poller) {
this._poller.stop();
this._poller = null;
}
if (this._timer) {
window.clearInterval(this._timer);
this._timer = null;
}
if (this._subscribed && window.app && window.app.pubsub) {
window.app.pubsub.unsubscribe(this._topic, this._onFrame);
this._subscribed = false;
}
}
_subscribe() {
const app = window.app;
if (!this._topic || !app || !app.pubsub || typeof app.pubsub.subscribe !== "function") return;
this._onFrame = (frame) => this._apply(frame);
app.pubsub.subscribe(this._topic, this._onFrame);
this._subscribed = true;
}
async _poll() {
if (this._status !== "active") return;
let payload;
try {
payload = await Http.getJson(`/battles/${this._uid}/events?after=${this._lastSeq}`);
} catch (error) {
return;
}
(payload.events || []).forEach((event) => this._apply(event));
if (payload.status === "resolved" && this._status === "active") {
this._refreshState();
}
}
_apply(event) {
if (!event || typeof event.seq !== "number") return;
if (event.seq > 0 && event.seq <= this._lastSeq) return;
if (event.seq > 0) this._lastSeq = event.seq;
if (typeof event.hp_a === "number" && typeof event.hp_b === "number") {
this._renderTotals(event.hp_a, event.hp_b);
}
this._prependEvent(event);
if (event.kind === "result") {
this._refreshState();
}
}
async _refreshState() {
let war;
try {
war = await Http.getJson(`/battles/${this._uid}`);
} catch (error) {
return;
}
this._renderWar(war);
}
async _onSubmit(event) {
const form = event.target.closest("form[data-war-action]");
if (!form || !this.contains(form)) return;
event.preventDefault();
event.stopPropagation();
const action = form.dataset.warAction;
const confirmText = form.dataset.warConfirm;
if (confirmText && window.app && window.app.dialog) {
const accepted = await window.app.dialog.confirm({ message: confirmText });
if (!accepted) return;
}
const button = form.querySelector("button[type=submit]");
if (button) button.disabled = true;
const params = {};
new FormData(form).forEach((value, key) => {
params[key] = value;
});
try {
const result = await Http.send(form.getAttribute("action"), params);
const war = result && result.data ? result.data.war : null;
if (action === "fight" && war) {
this._renderWar(war);
this._toast(`You dealt ${result.data.damage} HP!`);
} else {
window.location.reload();
}
} catch (error) {
this._toast(String(error && error.message ? error.message : error));
if (button) button.disabled = false;
}
}
_toast(message) {
if (window.app && window.app.toast && typeof window.app.toast.show === "function") {
window.app.toast.show(message, { type: "info" });
}
}
_renderTotals(hpA, hpB) {
const total = Math.max(0, hpA) + Math.max(0, hpB);
const pctA = total ? Math.round((Math.max(0, hpA) * 100) / total) : 50;
const pctB = total ? 100 - pctA : 50;
this._setText("[data-war-hp-a]", hpA.toLocaleString());
this._setText("[data-war-hp-b]", hpB.toLocaleString());
this._setText("[data-war-pct-a]", `${pctA}%`);
this._setText("[data-war-pct-b]", `${pctB}%`);
const barA = this.querySelector("[data-war-bar-a]");
const barB = this.querySelector("[data-war-bar-b]");
if (barA) barA.style.setProperty("--war-pct", `${pctA}%`);
if (barB) barB.style.setProperty("--war-pct", `${pctB}%`);
if (this._field) {
this._field.classList.toggle("war-field-lead-a", hpA > hpB);
this._field.classList.toggle("war-field-lead-b", hpB > hpA);
}
}
_setText(selector, value) {
const node = this.querySelector(selector);
if (node) node.textContent = value;
}
_renderTicker(events) {
if (!this._ticker || !events.length) return;
this._ticker.textContent = "";
events.slice(0, TICKER_CAP).forEach((event) => {
this._ticker.appendChild(this._buildEvent(event, ""));
});
}
_buildEvent(event, timeLabel) {
const item = document.createElement("li");
item.className = `war-event war-event-${event.kind}`;
const dot = document.createElement("span");
dot.className = `war-event-dot war-dot-${event.faction || "n"}`;
dot.setAttribute("aria-hidden", "true");
const message = document.createElement("span");
message.className = "war-event-message";
message.textContent = event.message;
item.append(dot, message);
if (timeLabel) {
const time = document.createElement("span");
time.className = "war-event-time";
time.textContent = timeLabel;
item.appendChild(time);
}
return item;
}
_prependEvent(event) {
if (!this._ticker || !event.message) return;
const empty = this._ticker.querySelector(".war-event-empty");
if (empty) empty.remove();
this._ticker.prepend(this._buildEvent(event, "just now"));
while (this._ticker.children.length > TICKER_CAP) {
this._ticker.removeChild(this._ticker.lastChild);
}
}
_renderWar(war) {
if (!war || !war.uid) return;
this._renderTotals(war.hp_a, war.hp_b);
if (typeof war.last_seq === "number" && war.last_seq > this._lastSeq) {
this._lastSeq = war.last_seq;
this._renderTicker(war.recent_events || []);
}
if (war.viewer) {
this._setText("[data-war-mine-hp]", war.viewer.hp.toLocaleString());
const fight = this.querySelector("[data-war-fight]");
if (fight) {
fight.disabled = !war.viewer.can_fight || war.status !== "active";
fight.textContent = war.viewer.can_fight
? `Fight (${war.fight_cost} coins)`
: "On cooldown";
}
}
if (war.status === "resolved" && this._status === "active") {
this._finishResolved(war);
}
if (war.ends_at) this._endsAt = war.ends_at;
}
_finishResolved(war) {
this._status = "resolved";
this._teardown();
if (this._actions) this._actions.hidden = true;
if (this._countdown) this._countdown.textContent = "Battle ended";
if (this._victoryTitle) {
this._victoryTitle.textContent =
war.winner === "draw"
? `It's a draw at ${war.hp_a.toLocaleString()} HP each`
: `${war.winner_label} wins the war!`;
}
if (this._victory) this._victory.hidden = false;
}
_tickCountdown() {
if (!this._countdown || this._status !== "active") return;
const ends = Date.parse(this._endsAt);
if (Number.isNaN(ends)) return;
const remaining = ends - Date.now();
if (remaining <= 0) {
this._countdown.textContent = "Battle ended";
this._refreshState();
return;
}
const minutes = Math.floor(remaining / 60000);
const days = Math.floor(minutes / 1440);
const hours = Math.floor((minutes % 1440) / 60);
const mins = minutes % 60;
const label = days ? `${days}d ${hours}h ${mins}m` : hours ? `${hours}h ${mins}m` : `${Math.max(mins, 1)}m`;
this._countdown.textContent = `Battle ends in ${label}`;
}
}
customElements.define("dp-opinion-war", AppOpinionWar);
@@ -16,7 +16,9 @@ export class AppQuizPlayer extends Component {
this._progress = this.querySelector("[data-quiz-progress]");
this._score = this.querySelector("[data-quiz-score]");
this._timer = this.querySelector("[data-quiz-timer]");
this._slides = Array.from(this.querySelectorAll("[data-quiz-slide]"));
this._bindForms();
if (this._slides.length) this._initSlides();
if (this._hasLimit && this._status === "in_progress") this._startTimer();
}
@@ -24,6 +26,46 @@ export class AppQuizPlayer extends Component {
if (this._interval) clearInterval(this._interval);
}
_initSlides() {
const startAt = this._slides.findIndex((slide) => !slide.classList.contains("quiz-question-answered"));
this._buildSlideNav();
this._showSlide(startAt === -1 ? this._slides.length - 1 : startAt);
}
_buildSlideNav() {
const host = this.querySelector("[data-quiz-slides]");
if (!host) return;
const nav = document.createElement("div");
nav.className = "quiz-slide-nav";
this._prevBtn = document.createElement("button");
this._prevBtn.type = "button";
this._prevBtn.className = "btn btn-secondary";
this._prevBtn.textContent = "Previous";
this._prevBtn.addEventListener("click", () => this._showSlide(this._current - 1));
this._counter = document.createElement("span");
this._counter.className = "quiz-slide-counter";
this._nextBtn = document.createElement("button");
this._nextBtn.type = "button";
this._nextBtn.className = "btn btn-secondary";
this._nextBtn.textContent = "Next";
this._nextBtn.addEventListener("click", () => this._showSlide(this._current + 1));
nav.appendChild(this._prevBtn);
nav.appendChild(this._counter);
nav.appendChild(this._nextBtn);
host.before(nav);
}
_showSlide(index) {
if (!this._slides.length) return;
this._current = Math.max(0, Math.min(this._slides.length - 1, index));
this._slides.forEach((slide, i) => {
slide.hidden = i !== this._current;
});
if (this._prevBtn) this._prevBtn.disabled = this._current === 0;
if (this._nextBtn) this._nextBtn.disabled = this._current === this._slides.length - 1;
if (this._counter) this._counter.textContent = `Question ${this._current + 1} of ${this._slides.length}`;
}
_bindForms() {
this.querySelectorAll("[data-quiz-answer-form]").forEach((form) => {
form.addEventListener("submit", (event) => {
@@ -133,9 +175,9 @@ export class AppQuizPlayer extends Component {
_advance(form) {
const question = form.closest("[data-quiz-question]");
if (!question) return;
const next = question.nextElementSibling;
if (next && next.scrollIntoView) next.scrollIntoView({ block: "start", behavior: "smooth" });
if (!question || !this._slides.length) return;
const index = this._slides.indexOf(question);
if (index !== -1 && index < this._slides.length - 1) this._showSlide(index + 1);
}
}
@@ -91,6 +91,9 @@ export class AppUpload extends Component {
this.pendingSubmitForm = null;
const form = this.closest("form");
if (form) {
if (this.boolAttr("paste")) {
form.addEventListener("paste", (event) => this.handlePaste(event));
}
form.addEventListener("submit", (event) => {
if (this.busyCount > 0) {
event.preventDefault();
@@ -108,6 +111,19 @@ export class AppUpload extends Component {
this.input.click();
}
handlePaste(event) {
const data = event.clipboardData;
const files = Array.from(data ? data.files : [])
.filter((file) => file.type.startsWith("image/"));
if (!files.length) {
return;
}
if (!data.getData("text/plain")) {
event.preventDefault();
}
this.handleFiles(files);
}
clear() {
this.items = [];
if (this.mode === "field") {
+1
View File
@@ -15,4 +15,5 @@ import "./AppIsslopRun.js";
import "./AppChat.js";
import "./AppQuizPlayer.js";
import "./AppQuizBuilder.js";
import "./AppOpinionWar.js";
import "./ContainerTerminal.js";
@@ -1,10 +1,9 @@
<div class="attachment-gallery">
{% set _single = attachments|length == 1 %}
<div class="attachment-gallery{% if _single %} single{% endif %}">
{% for att in attachments %}
<div class="attachment-gallery-item">
{% if att.get('is_image') and att.get('thumbnail_url') %}
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% elif att.get('is_image') %}
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% if att.get('is_image') %}
<img src="{{ att['url'] if _single or not att.get('thumbnail_url') else att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% elif att.get('is_video') %}
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
{% elif att.get('is_audio') %}
+1 -1
View File
@@ -1,4 +1,4 @@
<dp-upload multiple
<dp-upload multiple paste
max-size="{{ max_upload_size_mb() }}"
max-files="{{ max_attachments_per_resource() }}"
allowed-types="{{ allowed_file_types() }}"></dp-upload>
+1 -1
View File
@@ -5,7 +5,7 @@
{% set _user = user %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<textarea name="content" placeholder="Your opinion goes here..." required aria-required="true" aria-label="Comment" maxlength="125000" class="emoji-picker-target" data-mention></textarea>
<div class="comment-form-actions">
<dp-upload multiple
<dp-upload multiple paste
max-size="{{ max_upload_size_mb() }}"
max-files="{{ max_attachments_per_resource() }}"
allowed-types="{{ allowed_file_types() }}"></dp-upload>
+6
View File
@@ -8,4 +8,10 @@
<a href="/docs/privacy.html"><span class="icon">&#x1F512;</span> Privacy</a>
<a href="/docs/community-guidelines.html"><span class="icon">&#x1F91D;</span> Guidelines</a>
<a href="/docs/contact.html"><span class="icon">&#x2709;&#xFE0F;</span> Contact</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="app-store-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="Download on the App Store" class="app-store-badge" width="120" height="40" loading="lazy">
</a>
{% endif %}
</nav>
+95
View File
@@ -0,0 +1,95 @@
<dp-opinion-war class="war-card" uid="{{ _war.uid }}" topic="public.battle.{{ _war.uid }}" seq="{{ _war.last_seq }}" ends-at="{{ _war.ends_at }}" status="{{ _war.status }}">
<div class="war-head">
<span class="war-head-title"><span class="icon" aria-hidden="true">&#x2694;&#xFE0E;</span> Opinion War</span>
<span class="war-countdown" data-war-countdown>{% if _war.status == 'active' %}Battle ends in {{ _war.ends_in }}{% else %}Battle ended{% endif %}</span>
</div>
<div class="war-bars">
<div class="war-side war-side-a">
<span class="war-side-name">{{ render_title(_war.faction_a) }}</span>
<div class="war-bar war-bar-a"><span class="war-bar-fill" data-war-bar-a style="--war-pct: {{ _war.pct_a }}%;"></span><span class="war-bar-pct" data-war-pct-a>{{ _war.pct_a }}%</span></div>
<span class="war-side-hp"><span data-war-hp-a>{{ "{:,}".format(_war.hp_a) }}</span> HP</span>
</div>
<span class="war-vs" aria-hidden="true">VS</span>
<div class="war-side war-side-b">
<span class="war-side-name">{{ render_title(_war.faction_b) }}</span>
<div class="war-bar war-bar-b"><span class="war-bar-fill" data-war-bar-b style="--war-pct: {{ _war.pct_b }}%;"></span><span class="war-bar-pct" data-war-pct-b>{{ _war.pct_b }}%</span></div>
<span class="war-side-hp"><span data-war-hp-b>{{ "{:,}".format(_war.hp_b) }}</span> HP</span>
</div>
</div>
{% include "_opinion_war_field.html" %}
<div class="war-victory" data-war-victory{% if _war.status != 'resolved' %} hidden{% endif %}>
<span class="war-victory-title" data-war-victory-title>
{%- if _war.winner == 'draw' -%}
It's a draw at {{ "{:,}".format(_war.hp_a) }} HP each
{%- elif _war.winner_label -%}
{{ _war.winner_label }} wins the war!
{%- endif -%}
</span>
</div>
{% if _war.viewer %}
<div class="war-mine" data-war-mine>
<span class="war-mine-faction war-tag-{{ _war.viewer.faction }}">{{ render_title(_war.viewer.faction == 'a' and _war.faction_a or _war.faction_b) }}</span>
<span class="war-mine-stats">Rank #{{ _war.viewer.rank }} &middot; <span data-war-mine-hp>{{ "{:,}".format(_war.viewer.hp) }}</span> HP dealt</span>
</div>
{% endif %}
{% if _war.status == 'active' %}
<div class="war-actions" data-war-actions>
{% if not _war.viewer %}
<form method="POST" action="/battles/{{ _war.uid }}/join" data-war-action="join" class="war-join-form">
<input type="hidden" name="faction" value="a">
<button type="submit" class="btn btn-secondary btn-sm war-join-btn war-join-a"{{ guest_disabled(user) }}>Join {{ render_title(_war.faction_a) }}</button>
</form>
<form method="POST" action="/battles/{{ _war.uid }}/join" data-war-action="join" class="war-join-form">
<input type="hidden" name="faction" value="b">
<button type="submit" class="btn btn-secondary btn-sm war-join-btn war-join-b"{{ guest_disabled(user) }}>Join {{ render_title(_war.faction_b) }}</button>
</form>
{% else %}
<form method="POST" action="/battles/{{ _war.uid }}/fight" data-war-action="fight" class="war-fight-form">
<button type="submit" class="btn btn-primary war-fight-btn" data-war-fight{% if not _war.viewer.can_fight %} disabled data-war-ready-at="{{ _war.viewer.next_fight_at }}"{% endif %}>
{%- if _war.viewer.can_fight %}Fight ({{ _war.fight_cost }} coins){% else %}On cooldown{% endif -%}
</button>
</form>
<form method="POST" action="/battles/{{ _war.uid }}/join" data-war-action="join" data-war-confirm="Switch to {{ _war.viewer.faction == 'a' and _war.faction_b or _war.faction_a }}? Damage you already dealt stays with its faction." class="war-switch-form">
<input type="hidden" name="faction" value="{{ _war.viewer.faction == 'a' and 'b' or 'a' }}">
<button type="submit" class="btn btn-secondary btn-sm war-switch-btn">Switch faction</button>
</form>
{% endif %}
</div>
{% endif %}
{% if _war.top_contributors %}
<div class="war-contributors">
<span class="war-block-label">Top contributors</span>
<ul class="war-contributor-list">
{% for fighter in _war.top_contributors %}
<li class="war-contributor">
{% set _user = fighter %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% set _user = fighter %}{% include "_user_link.html" %}
<span class="war-contributor-hp war-text-{{ fighter.faction }}">{{ "{:,}".format(fighter.hp) }} HP</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="war-events">
<span class="war-block-label">Live events</span>
<ul class="war-ticker" data-war-ticker>
{% for e in _war.recent_events %}
<li class="war-event war-event-{{ e.kind }}"><span class="war-event-dot war-dot-{{ e.faction or 'n' }}" aria-hidden="true"></span><span class="war-event-message">{{ e.message }}</span><span class="war-event-time">{{ dt_ago(e.created_at) }}</span></li>
{% else %}
<li class="war-event war-event-empty">No battle activity yet - be the first to fight!</li>
{% endfor %}
</ul>
</div>
<div class="war-foot">
<a href="/battles" class="war-all-link"><span class="icon" aria-hidden="true">&#x2694;&#xFE0E;</span> View All Battles</a>
{% set _type = "battle" %}{% set _uid = _war.uid %}{% set _owner = _war.author.uid %}{% set _owner_name = _war.author.username %}{% set _class = "war-foot-btn" %}{% include "_report_button.html" %}
</div>
</dp-opinion-war>
@@ -0,0 +1,14 @@
{% set _strength_a = 1 + (_war.pct_a // 30) %}
{% set _strength_b = 1 + (_war.pct_b // 30) %}
<div class="war-field{% if _war.leader %} war-field-lead-{{ _war.leader }}{% endif %}" data-war-field aria-hidden="true">
<div class="war-castle war-castle-a"><span class="war-px war-castle-px"></span><span class="war-px war-flag war-flag-a"></span></div>
<div class="war-troops war-troops-a">
{% for _ in range(_strength_a) %}<span class="war-soldier war-soldier-a"><span class="war-px war-soldier-px"></span></span>{% endfor %}
</div>
<div class="war-campfire"><span class="war-px war-fire-logs"></span><span class="war-px war-fire-flame"></span></div>
<div class="war-troops war-troops-b">
{% for _ in range(_strength_b) %}<span class="war-soldier war-soldier-b"><span class="war-px war-soldier-px"></span></span>{% endfor %}
</div>
<div class="war-castle war-castle-b"><span class="war-px war-castle-px"></span><span class="war-px war-flag war-flag-b"></span></div>
<div class="war-ground"></div>
</div>
+6 -1
View File
@@ -25,7 +25,8 @@
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
{% endif %}
{% if item.attachments %}
{% set attachments = item.get('attachments', []) %}
{% if attachments %}
{% include "_attachment_display.html" %}
{% endif %}
@@ -33,6 +34,10 @@
{% set _poll = item.poll %}{% include "_poll.html" %}
{% endif %}
{% if item.war %}
{% set _war = item.war %}{% include "_opinion_war.html" %}
{% endif %}
<div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">
@@ -41,6 +41,19 @@
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-war-toggle><span class="icon">&#x2694;&#xFE0F;</span> <span data-war-toggle-label>Start Opinion War</span></button>
</div>
<div class="war-builder" data-war-builder hidden>
<p class="war-builder-hint">Two factions battle for 7 days. Members pick a side and fight once a day.</p>
<div class="war-builder-row">
<input type="text" name="war_faction_a" maxlength="30" placeholder="Faction A (e.g. Tabs)" aria-label="Faction A name" disabled>
<span class="war-builder-vs">VS</span>
<input type="text" name="war_faction_b" maxlength="30" placeholder="Faction B (e.g. Spaces)" aria-label="Faction B name" disabled>
</div>
<p class="war-builder-error" data-war-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
+6
View File
@@ -30,6 +30,12 @@
<small>Public origin used for absolute links (SEO, container ingress /p/&lt;slug&gt;). Empty = derive from the request or the DEVPLACE_SITE_URL env var.</small>
</div>
<div class="admin-field">
<label for="ios_app_url">iOS App Store URL</label>
<input type="text" id="ios_app_url" name="ios_app_url" value="{{ settings.get('ios_app_url', '') }}" maxlength="300" placeholder="https://apps.apple.com/app/devplace/id0000000000">
<small>Official iOS app listing. The App Store badge links here in the footer and the top navigation. Empty hides the badges.</small>
</div>
<hr class="admin-settings-divider">
<h3 class="admin-settings-group-title">Uploads & Attachments</h3>
+15
View File
@@ -52,6 +52,7 @@
<link rel="stylesheet" href="{{ static_url('/static/css/lightbox.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/media.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/engagement.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/opinionwar.css') }}">
{% block extra_head %}{% endblock %}
{{ custom_css_tag(request) }}
{{ extra_head_tag() }}
@@ -74,6 +75,7 @@
<a href="/gists" class="topnav-link {{ nav_active(request, '/gists') }}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/quizzes" class="topnav-link {{ nav_active(request, '/quizzes') }}"><span class="icon">🧩</span> Quizzes</a>
<a href="/battles" class="topnav-link {{ nav_active(request, '/battles') }}"><span class="icon">&#x2694;&#xFE0E;</span> Battles</a>
{% if user %}<a href="/game" class="topnav-link {{ nav_active(request, '/game') }}"><span class="icon">🌱</span> Farm</a>{% endif %}
</div>
<div class="topnav-right">
@@ -123,6 +125,12 @@
{% else %}
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon"></span>Sign Up</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="app-store-nav-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="App Store" class="app-store-nav-badge" width="90" height="30" loading="lazy">
</a>
{% endif %}
{% endif %}
<button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu" aria-controls="mobile-panel" aria-expanded="false"></button>
</div>
@@ -138,6 +146,7 @@
<a href="/gists" class="topnav-mobile-link {{ nav_active(request, '/gists') }}"><span class="icon">📄</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/quizzes" class="topnav-mobile-link {{ nav_active(request, '/quizzes') }}"><span class="icon">🧩</span> Quizzes</a>
<a href="/battles" class="topnav-mobile-link {{ nav_active(request, '/battles') }}"><span class="icon">&#x2694;&#xFE0E;</span> Battles</a>
<a href="/leaderboard" class="topnav-mobile-link {{ nav_active(request, '/leaderboard') }}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}<a href="/game" class="topnav-mobile-link {{ nav_active(request, '/game') }}"><span class="icon">🌱</span> Farm</a>{% endif %}
<div class="topnav-mobile-divider"></div>
@@ -180,6 +189,12 @@
<div class="topnav-mobile-divider"></div>
<a href="/auth/login" class="topnav-mobile-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="topnav-mobile-link"><span class="icon"></span> Sign Up</a>
{% set _ios_url = ios_app_url() %}
{% if _ios_url %}
<a href="{{ _ios_url }}" class="topnav-mobile-link app-store-mobile-link" target="_blank" rel="noopener noreferrer" aria-label="Download the DevPlace app on the App Store">
<img src="{{ static_url('/static/app-store-badge.svg') }}" alt="Download on the App Store" class="app-store-mobile-badge" width="120" height="40" loading="lazy">
</a>
{% endif %}
{% endif %}
</nav>
</div>
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
{% endblock %}
{% block content %}
<h1 class="sr-only">Opinion Wars</h1>
<div class="feed-layout">
<aside class="sidebar-card" role="complementary" aria-label="Battle filters">
{% set _action = "/battles" %}{% set _placeholder = "Search battles..." %}{% set _hidden = {"filter": current_filter} %}{% include "_sidebar_search.html" %}
<div class="sidebar-heading">Filters</div>
<div class="sidebar-nav">
<a href="/battles?filter=active" class="sidebar-link {% if current_filter == 'active' %}active{% endif %}">
<span class="icon">&#x2694;&#xFE0E;</span> Active <span class="war-filter-count">{{ counts.get('active', 0) }}</span>
</a>
<a href="/battles?filter=ended" class="sidebar-link {% if current_filter == 'ended' %}active{% endif %}">
<span class="icon">&#x1F3C6;</span> Ended <span class="war-filter-count">{{ counts.get('ended', 0) }}</span>
</a>
{% if user %}
<a href="/battles?filter=mine" class="sidebar-link {% if current_filter == 'mine' %}active{% endif %}">
<span class="icon">&#x1F6E1;&#xFE0E;</span> Mine <span class="war-filter-count">{{ counts.get('mine', 0) }}</span>
</a>
{% endif %}
</div>
{% if not user %}
<div class="sidebar-section">
<a href="/auth/login" class="sidebar-link"><span class="icon">🔑</span> Log in to fight</a>
</div>
{% endif %}
</aside>
<div class="feed-main">
<div class="feed-posts" role="feed" aria-label="Opinion Wars">
{% for battle in battles %}
<article class="post-card war-listing-item">
<div class="war-listing-head">
<a href="{{ battle.post_url }}" class="post-title-link"><h3 class="post-title">{{ render_title(battle.post_title) if battle.post_title else 'View post' }}</h3></a>
<span class="war-listing-meta">by {% set _user = battle.author %}{% include "_user_link.html" %} &middot; {{ dt_ago(battle.created_at) }}</span>
</div>
{% set _war = battle %}{% include "_opinion_war.html" %}
</article>
{% else %}
<div class="empty-state">No battles here yet. Start an Opinion War from the Create New Post form.</div>
{% endfor %}
</div>
{% set pagination_query = "filter=" ~ current_filter ~ "&" ~ ("search=" ~ search ~ "&" if search else "") %}
{% include "_pagination.html" %}
</div>
</div>
{% endblock %}
@@ -25,6 +25,7 @@ Set with the `mode` attribute:
| `label` | (none) | Optional button text shown beside the icon. |
| `multiple` | off | Allow selecting more than one file. |
| `directory` | off | Allow selecting a whole directory (`webkitdirectory`). |
| `paste` | off | Attach images pasted anywhere in the surrounding `<form>` (clipboard screenshots). |
| `accept` | (none) | Native accept filter, e.g. `image/*`. |
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
| `max-size` | `10` | Maximum size per file, in MB. |
@@ -0,0 +1,51 @@
<div class="docs-content" data-render>
# Opinion Wars
An Opinion War is a week-long battle between two factions, attached to a post. It is the
place to settle the eternal debates - tabs versus spaces, Python versus Rust - by showing
up every day and fighting for your side.
## Starting a war
Open **Create New Post** on the feed and press **Start Opinion War** (next to **Add
poll**). Name the two factions, write your post, and publish. The battle starts
immediately and runs for exactly **7 days**. A post carries at most one war, and it can
only be started when the post is created.
## Joining and fighting
Any signed-in member picks a side with the **Join** buttons on the battle card. Once
joined, the **Fight** button becomes yours:
- A fight costs **25 Code Farm coins** - earn them by playing [Code Farm]({{ base }}/game).
- Each fight deals damage for your faction: a base of 100 HP plus 10 HP per site level,
capped at level 20. A brand new member deals 110 HP; a veteran caps out at 300 HP.
There is no randomness - showing up daily beats everything else.
- You can fight **once every 24 hours** per battle. A notification tells you when your
next fight is ready.
You may switch factions at any time, but damage you already dealt stays with the faction
it was dealt to - defecting does not move your HP.
## Winning
When the week is over, the faction with more HP wins. Every fighter who fought at least
once earns XP, the winning side earns a bonus, and the single top damage dealer earns an
extra bonus. Equal totals are a draw with participation XP only. The card freezes into a
victory banner with the final totals and top contributors.
The battle card shows everything live: the HP bars, the countdown, your faction and rank,
the top contributors, and a ticker of joins, defections, fights and lead changes.
## The battle listing
Every battle - active and ended - is listed at [{{ base }}/battles]({{ base }}/battles),
searchable by faction name or creator, with an **Active**, **Ended** and **Mine** filter.
## The API
The full REST surface is documented in the [Opinion Wars API group]({{ base }}/docs/battles.html):
list battles, read one battle's state and event log, join a faction, and fight. Devii can
do all of it for you conversationally - it will ask for confirmation before joining or
spending your coins.
</div>
+1 -1
View File
@@ -70,7 +70,7 @@
<form class="messages-input-area" method="POST" action="/messages/send" data-live-form>
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
<textarea name="content" placeholder="Type a message..." maxlength="2000" autocomplete="off" data-mention aria-label="Type a message" rows="1"></textarea>
<dp-upload multiple
<dp-upload multiple paste
max-size="{{ max_upload_size_mb() }}"
max-files="5"
allowed-types="{{ allowed_file_types() }}"></dp-upload>
+4
View File
@@ -44,6 +44,10 @@
{% set _poll = poll %}{% include "_poll.html" %}
{% endif %}
{% if war %}
{% set _war = war %}{% include "_opinion_war.html" %}
{% endif %}
<div class="post-detail-actions">
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
+1 -1
View File
@@ -69,7 +69,7 @@
</article>
{% if leaderboard %}
<section class="quiz-leaderboard" aria-label="Quiz leaderboard">
<section id="leaderboard" class="quiz-leaderboard" aria-label="Quiz leaderboard">
<h2>Leaderboard</h2>
<ol class="quiz-leaderboard-list">
{% for entry in leaderboard %}
+1 -1
View File
@@ -46,7 +46,7 @@
<form method="POST" action="{{ quiz.url }}/attempts" class="inline-form">
<button type="submit" class="btn btn-primary">Play again</button>
</form>
<a href="{{ quiz.url }}/leaderboard" class="btn btn-secondary">Leaderboard</a>
<a href="{{ quiz.url }}#leaderboard" class="btn btn-secondary">Leaderboard</a>
<a href="/quizzes" class="btn btn-secondary">All quizzes</a>
</div>
</div>
+5
View File
@@ -194,6 +194,10 @@ def contact_details() -> dict[str, str]:
}
def ios_app_url() -> str:
return get_setting("ios_app_url", "").strip()
def policy_version(kind: str) -> str:
return get_setting(f"{kind}_version", "1") or "1"
@@ -211,6 +215,7 @@ def ai_provider_name() -> str:
templates.env.globals["contact_details"] = contact_details
templates.env.globals["ios_app_url"] = ios_app_url
templates.env.globals["policy_version"] = policy_version
templates.env.globals["moderation_sla_hours"] = moderation_sla_hours
templates.env.globals["moderation_minimum_age"] = moderation_minimum_age
+1
View File
@@ -21,6 +21,7 @@ The top-nav bell and Messages link carry `data-counter="notifications"` / `data-
| `level` | Reach a new level | `utils.py` `award_xp` | `new_level > current_level` |
| `badge` | Earn any badge | `utils.py` `notify_badge` | First grant only (`award_badge` returns `True`) |
| `issue` | Issue filed/replied/status | `issues.py`, `services/gitea`, `services/jobs/issue_create_service.py` | Reporter or admins |
| `battle` | Opinion War lead change / result / fight-ready | `services/opinionwar/store.py` (lead change + result), `services/opinionwar/service.py` (cooldown sweep) | Fighters of that war; lead change excludes the actor |
The `moderation` type carries both faces of the safety layer: the acknowledgement (with the published response window) sent to a reporter the moment they file, and the **statement of reasons** sent to a user whose content or account was actioned. Both are ordinary `create_notification` calls through the single funnel, so a user can turn the channel off per preference like any other type - what they cannot turn off is the decision itself.
+3
View File
@@ -117,6 +117,9 @@ from devplacepy.utils.rewards import (
XP_QUIZ,
XP_QUIZ_PUBLISH,
XP_QUIZ_COMPLETE,
XP_BATTLE_PART,
XP_BATTLE_WIN,
XP_BATTLE_TOP,
level_for_xp,
award_xp,
_COUNT_MILESTONES,
+4
View File
@@ -55,6 +55,10 @@ BADGE_CATALOG = {
"Researcher": {"icon": "🔬", "description": "Ran a DeepSearch investigation", "group": "Explorer"},
"Deep Diver": {"icon": "🌊", "description": "Ran 10 DeepSearch investigations", "group": "Explorer"},
"Container Captain": {"icon": "📦", "description": "Created a container instance", "group": "Explorer"},
"Instigator": {"icon": "🎺", "description": "Started an Opinion War", "group": "Engagement"},
"First Blood": {"icon": "", "description": "Fought in an Opinion War", "group": "Engagement"},
"War Veteran": {"icon": "🛡", "description": "Fought 50 Opinion War battles", "group": "Engagement"},
"Champion": {"icon": "🏅", "description": "Won an Opinion War", "group": "Engagement"},
"Messenger": {"icon": "", "description": "Sent your first direct message", "group": "Community"},
"Chatterbox": {"icon": "📨", "description": "Sent 100 direct messages", "group": "Community"},
"Bookmarker": {"icon": "🔖", "description": "Bookmarked your first item", "group": "Engagement"},
+6
View File
@@ -22,6 +22,9 @@ XP_FOLLOW = 5
XP_QUIZ = 8
XP_QUIZ_PUBLISH = 12
XP_QUIZ_COMPLETE = 4
XP_BATTLE_PART = 5
XP_BATTLE_WIN = 15
XP_BATTLE_TOP = 10
def level_for_xp(xp: int) -> int:
@@ -175,6 +178,9 @@ ACHIEVEMENTS = {
"docs.read": [(1, "Curious"), (5, "Studious"), (15, "Scholar")],
"devii": [(1, "AI Curious"), (25, "AI Whisperer")],
"fork": [(1, "First Fork"), (5, "Forker")],
"battle_create": [(1, "Instigator")],
"battle_fight": [(1, "First Blood"), (50, "War Veteran")],
"battle_win": [(1, "Champion")],
"zip": [(1, "Archivist")],
"seo": [(1, "SEO Auditor")],
"isslop": [(1, "Slop Hunter")],
+5
View File
@@ -328,6 +328,11 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `poll.vote.cast` | `routers/polls.py` |
| `poll.vote.change` | `routers/polls.py` |
| `poll.vote.clear` | `routers/polls.py` |
| `battle.create` | `services/opinionwar/store.py` |
| `battle.join` | `services/opinionwar/store.py` |
| `battle.switch` | `services/opinionwar/store.py` |
| `battle.fight` | `services/opinionwar/store.py` |
| `battle.resolve` | `services/opinionwar/store.py` |
| `reaction.add` | `routers/reactions.py` |
| `reaction.remove` | `routers/reactions.py` |
| `vote.comment.clear` | `content.py` |
View File
+78
View File
@@ -0,0 +1,78 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from devplacepy.database import get_table, refresh_snapshot
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
_counter_battles = [0]
def _session_battles():
_counter_battles[0] += 1
name = f"war{int(time.time() * 1000)}{_counter_battles[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
return s, name
def _create_war_post(session, title, faction_a="Tabs", faction_b="Spaces"):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Opinion War host post content for tests.",
"title": title,
"topic": "question",
"war_faction_a": faction_a,
"war_faction_b": faction_b,
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
refresh_snapshot()
post = get_table("posts").find_one(slug=slug)
war = get_table("opinion_wars").find_one(post_uid=post["uid"])
return post, war
def _war_row(war_uid):
refresh_snapshot()
return get_table("opinion_wars").find_one(uid=war_uid)
def _fighter_row(war_uid, user_uid):
refresh_snapshot()
return get_table("opinion_war_fighters").find_one(
war_uid=war_uid, user_uid=user_uid
)
def _user_row(username):
refresh_snapshot()
return get_table("users").find_one(username=username)
def _join(session, war_uid, faction):
return session.post(
f"{BASE_URL}/battles/{war_uid}/join",
data={"faction": faction},
headers=JSON,
)
def _fight(session, war_uid):
return session.post(f"{BASE_URL}/battles/{war_uid}/fight", headers=JSON)
+165
View File
@@ -0,0 +1,165 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timedelta, timezone
import requests
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.opinionwar import rules
from tests.api.battles._helpers import (
JSON,
_create_war_post,
_fight,
_fighter_row,
_join,
_session_battles,
_user_row,
_war_row,
)
from tests.conftest import BASE_URL
def _warp_cooldown(war_uid, user_uid):
fighter = _fighter_row(war_uid, user_uid)
warped = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat()
get_table("opinion_war_fighters").update(
{"uid": fighter["uid"], "last_fight_at": warped}, ["uid"]
)
def test_join_and_switch(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"join-{int(time.time() * 1000)}")
r = _join(s, war["uid"], "a")
assert r.status_code == 200
payload = r.json()
assert payload["ok"] is True
assert payload["data"]["war"]["viewer"]["faction"] == "a"
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
assert fighter["faction"] == "a"
r = _join(s, war["uid"], "b")
assert r.json()["data"]["war"]["viewer"]["faction"] == "b"
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
assert fighter["faction"] == "b"
def test_join_invalid_faction_422(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"badfaction-{int(time.time() * 1000)}")
r = s.post(
f"{BASE_URL}/battles/{war['uid']}/join",
data={"faction": "c"},
headers=JSON,
)
assert r.status_code in (400, 422)
def test_join_requires_login(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"anon-{int(time.time() * 1000)}")
r = requests.post(
f"{BASE_URL}/battles/{war['uid']}/join",
data={"faction": "a"},
allow_redirects=False,
)
assert r.status_code in (303, 401)
def test_fight_spends_coins_and_deals_level_damage(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"fight-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
r = _fight(s, war["uid"])
assert r.status_code == 200
payload = r.json()
user = _user_row(name)
expected = rules.damage_for(user.get("level"))
assert payload["data"]["damage"] == expected
assert payload["data"]["war"]["hp_a"] == expected
war_row = _war_row(war["uid"])
assert int(war_row["hp_a"]) == expected
refresh_snapshot()
farm = get_table("game_farms").find_one(user_uid=user["uid"])
from devplacepy.services.game import economy
assert int(farm["coins"]) == economy.STARTING_COINS - rules.FIGHT_COST_COINS
def test_fight_cooldown_refused(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"cooldown-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
assert _fight(s, war["uid"]).status_code == 200
r = _fight(s, war["uid"])
assert r.status_code == 400
assert "24 hours" in r.json()["error"]["message"]
def test_fight_after_warped_cooldown_lands(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"warp-{int(time.time() * 1000)}")
_join(s, war["uid"], "b")
assert _fight(s, war["uid"]).status_code == 200
_warp_cooldown(war["uid"], _user_row(name)["uid"])
r = _fight(s, war["uid"])
assert r.status_code == 200
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
assert int(fighter["fight_count"]) == 2
def test_fight_without_coins_refused_and_cooldown_kept(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"broke-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
assert _fight(s, war["uid"]).status_code == 200
user = _user_row(name)
_warp_cooldown(war["uid"], user["uid"])
refresh_snapshot()
farm = get_table("game_farms").find_one(user_uid=user["uid"])
get_table("game_farms").update({"uid": farm["uid"], "coins": 0}, ["uid"])
r = _fight(s, war["uid"])
assert r.status_code == 400
assert "coins" in r.json()["error"]["message"]
fighter = _fighter_row(war["uid"], user["uid"])
assert int(fighter["fight_count"]) == 1
r = _fight(s, war["uid"])
assert r.status_code == 400
def test_fight_before_join_refused(app_server):
s, _ = _session_battles()
other, _ = _session_battles()
_, war = _create_war_post(s, f"nojoin-{int(time.time() * 1000)}")
r = _fight(other, war["uid"])
assert r.status_code == 400
assert "Join" in r.json()["error"]["message"]
def test_switch_keeps_dealt_damage(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"defect-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
assert _fight(s, war["uid"]).status_code == 200
r = _join(s, war["uid"], "b")
war_state = r.json()["data"]["war"]
user = _user_row(name)
expected = rules.damage_for(user.get("level"))
assert war_state["hp_a"] == expected
assert war_state["hp_b"] == 0
fighter = _fighter_row(war["uid"], user["uid"])
assert fighter["faction"] == "b"
assert int(fighter["hp_a"]) == expected
def test_actions_on_resolved_war_refused(app_server):
s, _ = _session_battles()
other, _ = _session_battles()
_, war = _create_war_post(s, f"over-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
get_table("opinion_wars").update({"uid": war["uid"], "ends_at": past}, ["uid"])
r = _fight(s, war["uid"])
assert r.status_code == 400
r = _join(other, war["uid"], "b")
assert r.status_code == 400
+43
View File
@@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.api.battles._helpers import _create_war_post, _fight, _join, _session_battles
from tests.conftest import BASE_URL
def test_events_ordered_and_incremental(app_server):
s, _ = _session_battles()
other, _ = _session_battles()
_, war = _create_war_post(s, f"events-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
_join(other, war["uid"], "b")
_fight(s, war["uid"])
r = requests.get(f"{BASE_URL}/battles/{war['uid']}/events")
assert r.status_code == 200
payload = r.json()
assert payload["status"] == "active"
seqs = [event["seq"] for event in payload["events"]]
assert seqs == sorted(seqs)
kinds = [event["kind"] for event in payload["events"]]
assert kinds[:3] == ["join", "join", "fight"]
assert "lead_change" in kinds
fight_event = next(e for e in payload["events"] if e["kind"] == "fight")
assert fight_event["faction"] == "a"
assert fight_event["hp_a"] > 0
after = seqs[1]
r = requests.get(
f"{BASE_URL}/battles/{war['uid']}/events", params={"after": after}
)
replay = [event["seq"] for event in r.json()["events"]]
assert replay == [seq for seq in seqs if seq > after]
def test_events_unknown_battle_404(app_server):
r = requests.get(f"{BASE_URL}/battles/nope/events")
assert r.status_code == 404
+73
View File
@@ -0,0 +1,73 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.api.battles._helpers import JSON, _create_war_post, _session_battles
from tests.conftest import BASE_URL
def test_listing_renders_battle_card(app_server):
s, _ = _session_battles()
_create_war_post(s, f"listing-{int(time.time() * 1000)}", "Vim", "Emacs")
r = s.get(f"{BASE_URL}/battles")
assert r.status_code == 200
assert "dp-opinion-war" in r.text
assert "Vim" in r.text
def test_listing_json_shape(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"json-{int(time.time() * 1000)}")
r = s.get(f"{BASE_URL}/battles", headers=JSON)
assert r.status_code == 200
data = r.json()
assert data["current_filter"] == "active"
assert set(data["counts"]) == {"active", "ended", "mine"}
uids = [battle["uid"] for battle in data["battles"]]
assert war["uid"] in uids
battle = next(b for b in data["battles"] if b["uid"] == war["uid"])
assert battle["faction_a"] == "Tabs"
assert battle["pct_a"] + battle["pct_b"] == 100
assert battle["fight_cost"] > 0
def test_listing_guest_access(app_server):
s, _ = _session_battles()
_create_war_post(s, f"guest-{int(time.time() * 1000)}")
r = requests.get(f"{BASE_URL}/battles")
assert r.status_code == 200
assert "dp-opinion-war" in r.text
def test_listing_search_matches_faction(app_server):
s, _ = _session_battles()
marker = f"Zx{int(time.time() * 1000) % 100000}"
_, war = _create_war_post(s, f"search-{marker}", marker, "Others")
r = s.get(f"{BASE_URL}/battles", params={"search": marker}, headers=JSON)
uids = [battle["uid"] for battle in r.json()["battles"]]
assert uids == [war["uid"]]
def test_listing_invalid_filter_falls_back(app_server):
s, _ = _session_battles()
r = s.get(f"{BASE_URL}/battles", params={"filter": "bogus"}, headers=JSON)
assert r.json()["current_filter"] == "active"
def test_battle_state_json(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"state-{int(time.time() * 1000)}")
r = requests.get(f"{BASE_URL}/battles/{war['uid']}")
assert r.status_code == 200
data = r.json()
assert data["uid"] == war["uid"]
assert data["status"] == "active"
assert data["viewer"] is None
assert data["ends_in"]
def test_battle_state_unknown_uid_404(app_server):
r = requests.get(f"{BASE_URL}/battles/does-not-exist", headers=JSON)
assert r.status_code == 404
+94
View File
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timedelta, timezone
import requests
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils.rewards import XP_BATTLE_PART, XP_BATTLE_TOP, XP_BATTLE_WIN
from tests.api.battles._helpers import (
_create_war_post,
_fight,
_join,
_session_battles,
_user_row,
_war_row,
)
from tests.conftest import BASE_URL
def _warp_ended(war_uid):
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
get_table("opinion_wars").update({"uid": war_uid, "ends_at": past}, ["uid"])
def test_resolution_awards_xp_exactly_once(app_server):
s, winner_name = _session_battles()
other, loser_name = _session_battles()
_, war = _create_war_post(s, f"resolve-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
_join(other, war["uid"], "b")
assert _fight(s, war["uid"]).status_code == 200
winner_before = int(_user_row(winner_name)["xp"] or 0)
loser_before = int(_user_row(loser_name)["xp"] or 0)
_warp_ended(war["uid"])
r = requests.get(f"{BASE_URL}/battles/{war['uid']}")
assert r.status_code == 200
state = r.json()
assert state["status"] == "resolved"
assert state["winner"] == "a"
assert state["winner_label"] == "Tabs"
row = _war_row(war["uid"])
assert row["status"] == "resolved"
assert row["resolved_at"]
winner_after = int(_user_row(winner_name)["xp"] or 0)
loser_after = int(_user_row(loser_name)["xp"] or 0)
assert winner_after == winner_before + XP_BATTLE_PART + XP_BATTLE_WIN + XP_BATTLE_TOP
assert loser_after == loser_before
events = requests.get(f"{BASE_URL}/battles/{war['uid']}/events").json()["events"]
results = [event for event in events if event["kind"] == "result"]
assert len(results) == 1
refresh_snapshot()
notes = list(
get_table("notifications").find(
type="battle", user_uid=_user_row(loser_name)["uid"]
)
)
assert any("Battle over" in (n.get("message") or "") for n in notes)
r = requests.get(f"{BASE_URL}/battles/{war['uid']}")
assert r.json()["status"] == "resolved"
assert int(_user_row(winner_name)["xp"] or 0) == winner_after
events = requests.get(f"{BASE_URL}/battles/{war['uid']}/events").json()["events"]
assert len([e for e in events if e["kind"] == "result"]) == 1
def test_draw_awards_participation_only(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"draw-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
before = int(_user_row(name)["xp"] or 0)
_warp_ended(war["uid"])
state = requests.get(f"{BASE_URL}/battles/{war['uid']}").json()
assert state["winner"] == "draw"
assert int(_user_row(name)["xp"] or 0) == before
def test_post_page_resolves_due_war(app_server):
s, _ = _session_battles()
post, war = _create_war_post(s, f"lazy-{int(time.time() * 1000)}")
_join(s, war["uid"], "b")
assert _fight(s, war["uid"]).status_code == 200
_warp_ended(war["uid"])
slug = post.get("slug") or post["uid"]
r = s.get(f"{BASE_URL}/posts/{slug}")
assert r.status_code == 200
assert "wins the war" in r.text
assert _war_row(war["uid"])["status"] == "resolved"
assert _war_row(war["uid"])["winner"] == "b"
+106
View File
@@ -795,3 +795,109 @@ def test_feed_includes_project_when_linked(app_server):
assert "/projects/" in item["project_link"]["url"]
break
assert found, "feed must include project info for linked posts"
def test_create_post_with_war_creates_battle(app_server):
from devplacepy.services.opinionwar import rules
s, _ = _member()
r = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("auwar"),
"content": "this post carries an opinion war between two factions",
"topic": "question",
"war_faction_a": "Tabs",
"war_faction_b": "Spaces",
},
)
post_uid = r.json()["data"]["uid"]
refresh_snapshot()
war = get_table("opinion_wars").find_one(post_uid=post_uid)
assert war is not None
assert war["faction_a"] == "Tabs"
assert war["faction_b"] == "Spaces"
assert war["status"] == "active"
created = datetime.fromisoformat(war["created_at"])
ends = datetime.fromisoformat(war["ends_at"])
assert abs((ends - created).total_seconds() - rules.WAR_DURATION_DAYS * 86400) < 5
def test_create_post_with_one_faction_creates_no_war(app_server):
s, _ = _member()
r = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("auwarhalf"),
"content": "one faction alone starts no war on this post",
"topic": "question",
"war_faction_a": "Loners",
},
)
post_uid = r.json()["data"]["uid"]
refresh_snapshot()
assert get_table("opinion_wars").find_one(post_uid=post_uid) is None
def test_create_post_with_equal_factions_creates_no_war(app_server):
s, _ = _member()
r = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": _unique("auwareq"),
"content": "two identical faction names start no war here",
"topic": "question",
"war_faction_a": "Same",
"war_faction_b": "same",
},
)
post_uid = r.json()["data"]["uid"]
refresh_snapshot()
assert get_table("opinion_wars").find_one(post_uid=post_uid) is None
def test_create_post_with_poll_and_war_attaches_both(app_server):
s, _ = _member()
r = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data=[
("title", _unique("auwarpoll")),
("content", "this post carries a poll and an opinion war together"),
("topic", "question"),
("poll_question", "Which side?"),
("poll_options", "Tabs"),
("poll_options", "Spaces"),
("war_faction_a", "Tabs"),
("war_faction_b", "Spaces"),
],
)
post_uid = r.json()["data"]["uid"]
refresh_snapshot()
assert get_table("polls").find_one(post_uid=post_uid) is not None
assert get_table("opinion_wars").find_one(post_uid=post_uid) is not None
def test_feed_serializes_war_on_post(app_server):
s, _ = _member()
marker = _unique("auwarfeed")
r = s.post(
f"{BASE_URL}/posts/create",
headers=JSON_audit_log,
data={
"title": marker,
"content": "the feed must carry the serialized war for this post",
"topic": "question",
"war_faction_a": "Cats",
"war_faction_b": "Dogs",
},
)
post_uid = r.json()["data"]["uid"]
feed = s.get(f"{BASE_URL}/feed", headers=JSON_audit_log).json()
item = next(i for i in feed["posts"] if i["post"]["uid"] == post_uid)
assert item["war"] is not None
assert item["war"]["faction_a"] == "Cats"
assert item["war"]["pct_a"] == 50
+42
View File
@@ -319,6 +319,48 @@ def login_user(page, user):
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def paste_image(page, selector, name="pasted.png"):
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
page.eval_on_selector(
selector,
"""(target, [data, filename]) => {
const bytes = Uint8Array.from(atob(data), (ch) => ch.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], filename, { type: "image/png" }));
target.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData: transfer,
bubbles: true,
cancelable: true,
})
);
}""",
[base64.b64encode(buf.getvalue()).decode(), name],
)
def create_post_with_files(page, content, files, expected_count=1):
from playwright.sync_api import expect
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.fill("#post-content", content)
page.locator("#create-post-modal dp-upload .dp-upload-input").first.set_input_files(
files
)
expect(
page.locator("#create-post-modal dp-upload .dp-upload-count").first
).to_have_text(f"({expected_count})", timeout=15000)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def assert_share_copies(page, expected_fragment):
from playwright.sync_api import expect
View File
+156
View File
@@ -0,0 +1,156 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from playwright.sync_api import expect
from tests.conftest import BASE_URL
_counter_wars = [0]
def _seed_war(faction_a="Tabs", faction_b="Spaces"):
_counter_wars[0] += 1
name = f"ewar{int(time.time() * 1000)}{_counter_wars[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
r = s.post(
f"{BASE_URL}/posts/create",
data={
"content": "Seeded battle post for browser tests.",
"title": f"battle-{name}",
"topic": "question",
"war_faction_a": faction_a,
"war_faction_b": faction_b,
},
allow_redirects=False,
)
return r.headers["location"].split("/posts/")[-1]
def _open_composer(page):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible")
page.locator(".feed-fab").first.click()
page.locator("#create-post-modal.visible").wait_for(state="visible")
def test_battles_page_lists_cards(alice):
page, _ = alice
_seed_war("Vikings", "Knights")
page.goto(f"{BASE_URL}/battles", wait_until="domcontentloaded")
page.locator("dp-opinion-war").first.wait_for(state="visible")
expect(page.locator(".topnav-link.active:has-text('Battles')")).to_be_visible()
expect(page.locator("dp-opinion-war .war-vs").first).to_have_text("VS")
def test_battle_card_on_post_page(alice):
page, _ = alice
slug = _seed_war("Ninjas", "Pirates")
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
card = page.locator("dp-opinion-war")
card.wait_for(state="visible")
expect(card.locator(".war-side-a .war-side-name")).to_have_text("Ninjas")
expect(card.locator(".war-field")).to_be_visible()
expect(card.locator(".war-all-link")).to_have_attribute("href", "/battles")
def test_composer_war_toggle_and_poll_exclusivity(alice):
page, _ = alice
_open_composer(page)
modal = page.locator("#create-post-modal")
expect(modal.locator("[data-war-builder]")).to_be_hidden()
modal.locator("[data-war-toggle]").click()
expect(modal.locator("[data-war-builder]")).to_be_visible()
expect(modal.locator("input[name='war_faction_a']")).to_be_enabled()
modal.locator("[data-poll-toggle]").click()
expect(modal.locator("[data-poll-builder]")).to_be_visible()
expect(modal.locator("[data-war-builder]")).to_be_hidden()
modal.locator("[data-war-toggle]").click()
expect(modal.locator("[data-war-builder]")).to_be_visible()
expect(modal.locator("[data-poll-builder]")).to_be_hidden()
def test_composer_war_validation_blocks_submit(alice):
page, _ = alice
_open_composer(page)
modal = page.locator("#create-post-modal")
page.fill("#post-content", "A war with only one named faction must not submit.")
modal.locator("[data-war-toggle]").click()
modal.locator("input[name='war_faction_a']").fill("Solo")
modal.locator("button.btn-primary:has-text('Post')").click()
expect(modal.locator("[data-war-error]")).to_be_visible()
assert "/feed" in page.url
def test_create_join_and_fight_flow(alice):
page, _ = alice
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.game.store import ensure_farm
refresh_snapshot()
row = get_table("users").find_one(username="alice_test")
farm = ensure_farm(row["uid"])
get_table("game_farms").update({"uid": farm["uid"], "coins": 1000}, ["uid"])
_open_composer(page)
modal = page.locator("#create-post-modal")
page.fill("#post-content", "Full battle flow: create, join and fight in browser.")
modal.locator("[data-war-toggle]").click()
modal.locator("input[name='war_faction_a']").fill("Coffee")
modal.locator("input[name='war_faction_b']").fill("Tea")
modal.locator("button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
card = page.locator("dp-opinion-war")
card.wait_for(state="visible")
card.locator(".war-join-a").click()
card.locator("[data-war-mine]").wait_for(state="visible")
hp_before = card.locator("[data-war-hp-a]").text_content()
card.locator("[data-war-fight]").click()
page.wait_for_function(
"before => document.querySelector('[data-war-hp-a]').textContent !== before",
arg=hp_before,
)
expect(card.locator("[data-war-fight]")).to_be_disabled()
expect(card.locator("[data-war-ticker]")).to_contain_text("dealt")
def test_switch_faction_shows_confirm(alice):
page, _ = alice
slug = _seed_war("Left", "Right")
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
card = page.locator("dp-opinion-war")
card.wait_for(state="visible")
card.locator(".war-join-b").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
card = page.locator("dp-opinion-war")
card.locator(".war-switch-btn").wait_for(state="visible")
card.locator(".war-switch-btn").click()
page.locator(".dialog-overlay.visible .dialog-confirm").wait_for(state="visible")
page.locator(".dialog-overlay.visible .dialog-confirm").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
card = page.locator("dp-opinion-war")
expect(card.locator(".war-mine-faction")).to_have_text("Left")
def test_guest_sees_disabled_actions(page, app_server):
slug = _seed_war("Sun", "Moon")
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
card = page.locator("dp-opinion-war")
card.wait_for(state="visible")
expect(card.locator(".war-join-a")).to_be_disabled()
expect(card.locator(".war-join-b")).to_be_disabled()
+41 -1
View File
@@ -1,6 +1,6 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, create_post_with_files, paste_image
import time
import requests
from playwright.sync_api import expect
@@ -702,6 +702,46 @@ def test_feed_politics_topic(alice):
assert politics_link.is_visible()
def test_paste_image_attaches_in_post_composer(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.locator("#post-content").wait_for(state="visible", timeout=10000)
page.locator("#create-post-modal dp-upload .dp-upload-btn").first.wait_for(
state="visible", timeout=10000
)
paste_image(page, "#post-content")
page.locator("#create-post-modal dp-upload .dp-upload-count").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator("#create-post-modal dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
def test_feed_card_shows_the_post_image(alice):
import io
from PIL import Image
page, _ = alice
buf = io.BytesIO()
Image.new("RGB", (600, 400), (28, 120, 200)).save(buf, "PNG")
create_post_with_files(
page,
"Feed card image rendering check",
[{"name": "card.png", "mimeType": "image/png", "buffer": buf.getvalue()}],
)
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
card = page.locator(
".post-card:has-text('Feed card image rendering check')"
).first
card.wait_for(state="visible", timeout=10000)
image = card.locator(".attachment-gallery.single .gallery-thumb").first
image.wait_for(state="visible", timeout=10000)
assert "_thumb" not in image.get_attribute("src")
def test_create_post_cancel_modal(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
+93
View File
@@ -0,0 +1,93 @@
# retoor <retoor@molodetz.nl>
import time
from devplacepy.database import get_setting, set_setting
from tests.conftest import BASE_URL, CACHE_VERSION_PROPAGATION_SECONDS
IOS_SETTING = "ios_app_url"
IOS_APP_URL = "https://apps.apple.com/app/devplace/id6797215143"
LANDING_FOOTER_BADGE = ".landing-footer .app-store-link"
SITE_FOOTER_BADGE = ".site-footer .app-store-link"
NAV_BADGE = ".topnav-right .app-store-nav-link"
MOBILE_BADGE = ".topnav-mobile-panel .app-store-mobile-link"
def _configure():
original = get_setting(IOS_SETTING, "")
set_setting(IOS_SETTING, IOS_APP_URL)
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
return original
def _assert_badge(badge):
badge.wait_for(state="visible")
assert badge.get_attribute("href") == IOS_APP_URL
assert badge.get_attribute("target") == "_blank"
assert badge.get_attribute("rel") == "noopener noreferrer"
def test_footer_app_store_badge_on_landing_page(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
_assert_badge(page.locator(LANDING_FOOTER_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_footer_app_store_badge_on_regular_page(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
_assert_badge(page.locator(SITE_FOOTER_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_topnav_app_store_badge_next_to_login_signup(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
_assert_badge(page.locator(NAV_BADGE))
assert page.locator(".topnav-right a[href='/auth/login']").is_visible()
assert page.locator(".topnav-right a[href='/auth/signup']").is_visible()
finally:
set_setting(IOS_SETTING, original)
def test_mobile_menu_app_store_badge(page, app_server):
original = _configure()
try:
page.set_viewport_size({"width": 390, "height": 844})
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
hamburger = page.locator("#hamburger-btn")
hamburger.wait_for(state="visible")
hamburger.click()
page.locator("#mobile-panel.open").wait_for(state="visible")
_assert_badge(page.locator(MOBILE_BADGE))
finally:
set_setting(IOS_SETTING, original)
def test_badges_hide_when_ios_app_url_is_cleared(page, app_server):
original = _configure()
try:
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.locator(LANDING_FOOTER_BADGE).count() == 1
set_setting(IOS_SETTING, "")
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded")
assert page.locator(LANDING_FOOTER_BADGE).count() == 0
assert page.locator(NAV_BADGE).count() == 0
assert page.locator(MOBILE_BADGE).count() == 0
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.locator(SITE_FOOTER_BADGE).count() == 0
finally:
set_setting(IOS_SETTING, original)
+19 -1
View File
@@ -3,7 +3,7 @@
import re
import time
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from tests.conftest import BASE_URL, paste_image
from devplacepy.database import get_table
import requests
def _seed_news_seo():
@@ -183,6 +183,24 @@ def test_send_message_appears_in_thread(alice):
page.locator(f".message-bubble:has-text('{msg}')").first.wait_for(state="visible")
def test_paste_image_attaches_in_chat(alice):
page, _ = alice
bob = get_table("users").find_one(username="bob_test")
page.goto(
f"{BASE_URL}/messages?with_uid={bob['uid']}", wait_until="domcontentloaded"
)
page.locator(".messages-input-area dp-upload .dp-upload-btn").first.wait_for(
state="visible", timeout=10000
)
paste_image(page, ".messages-input-area textarea[name='content']")
page.locator(".messages-input-area dp-upload .dp-upload-count").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator(".messages-input-area dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
def test_messages_page_loads(alice):
page, _ = alice
page.goto(f"{BASE_URL}/messages", wait_until="domcontentloaded")
+93 -1
View File
@@ -2,7 +2,12 @@
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
from tests.conftest import (
BASE_URL,
assert_share_copies,
create_post_with_files,
paste_image,
)
def create_post(page, topic="random", content="Test post content", title=None):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
@@ -13,6 +18,27 @@ def create_post(page, topic="random", content="Test post content", title=None):
page.fill("#post-title", title)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def _png_bytes(color=(0, 128, 255), size=(600, 400)):
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, "PNG")
return buf.getvalue()
def _gif_bytes():
import io
from PIL import Image
frames = [Image.new("RGB", (40, 40), c) for c in ((255, 0, 0), (0, 0, 255))]
buf = io.BytesIO()
frames[0].save(
buf, "GIF", save_all=True, append_images=frames[1:], duration=120, loop=0
)
return buf.getvalue()
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(
@@ -375,6 +401,72 @@ def test_emoji_picker_opens(alice):
assert page.locator("emoji-picker").first.is_visible()
def test_paste_image_attaches_in_comment_form(alice):
page, _ = alice
create_post(page, "random", "Post for pasted comment attachment")
page.locator(".comment-form dp-upload .dp-upload-btn").first.wait_for(
state="visible", timeout=10000
)
paste_image(page, ".comment-form textarea[name='content']")
page.locator(".comment-form dp-upload .dp-upload-count").first.wait_for(
state="visible", timeout=15000
)
expect(
page.locator(".comment-form dp-upload input[name='attachment_uids']")
).to_have_value(re.compile(r".+"))
def test_single_image_post_shows_the_original_full_size(alice):
page, _ = alice
create_post_with_files(
page,
"Post carrying exactly one image",
[{"name": "shot.png", "mimeType": "image/png", "buffer": _png_bytes()}],
)
gallery = page.locator(".attachment-gallery.single")
gallery.wait_for(state="visible", timeout=10000)
img = gallery.locator(".gallery-thumb").first
src = img.get_attribute("src")
assert "_thumb" not in src, f"hero image served the 200px thumbnail: {src}"
assert src == img.get_attribute("data-full")
def test_multiple_image_post_keeps_thumbnails(alice):
page, _ = alice
create_post_with_files(
page,
"Post carrying two images",
[
{"name": "one.png", "mimeType": "image/png", "buffer": _png_bytes()},
{
"name": "two.png",
"mimeType": "image/png",
"buffer": _png_bytes((200, 30, 90)),
},
],
expected_count=2,
)
page.locator(".attachment-gallery").first.wait_for(state="visible", timeout=10000)
assert page.locator(".attachment-gallery.single").count() == 0
thumbs = page.locator(".attachment-gallery .gallery-thumb")
assert thumbs.count() == 2
for i in range(thumbs.count()):
assert "_thumb" in thumbs.nth(i).get_attribute("src")
def test_animated_gif_post_serves_the_original_file(alice):
page, _ = alice
create_post_with_files(
page,
"Post carrying an animated gif",
[{"name": "loop.gif", "mimeType": "image/gif", "buffer": _gif_bytes()}],
)
img = page.locator(".attachment-gallery .gallery-thumb").first
img.wait_for(state="visible", timeout=10000)
src = img.get_attribute("src")
assert src.endswith(".gif"), f"animation lost, served {src}"
def test_attachment_upload_ui(alice):
import io
from PIL import Image
+28 -1
View File
@@ -68,6 +68,33 @@ def test_starting_opens_the_player(alice):
page.locator(".quiz-hud").wait_for(state="visible")
def test_the_player_shows_one_question_at_a_time(alice):
page, _ = alice
slug = _author_quiz("Player one at a time quiz")
_start(page, slug)
questions = page.locator(".quiz-question")
assert questions.nth(0).is_visible()
assert not questions.nth(1).is_visible()
page.locator(".quiz-slide-nav button:has-text('Next')").click()
assert not questions.nth(0).is_visible()
assert questions.nth(1).is_visible()
page.locator(".quiz-slide-nav button:has-text('Previous')").click()
assert questions.nth(0).is_visible()
assert not questions.nth(1).is_visible()
def test_answering_advances_to_the_next_question(alice):
page, _ = alice
slug = _author_quiz("Player advance quiz")
_start(page, slug)
questions = page.locator(".quiz-question")
questions.nth(0).locator("input[type='radio']").last.check()
questions.nth(0).locator("button:has-text('Submit answer')").click()
questions.nth(0).locator(".quiz-grade").wait_for(state="visible")
questions.nth(1).wait_for(state="visible")
assert not questions.nth(0).is_visible()
def test_the_player_renders_a_real_form_per_question(alice):
page, _ = alice
slug = _author_quiz("Player forms quiz")
@@ -129,7 +156,7 @@ def test_an_answered_question_cannot_be_resubmitted(alice):
def test_a_numeric_question_takes_a_typed_answer(alice):
page, _ = alice
slug = _author_quiz("Player numeric quiz")
slug = _author_quiz("Player numeric quiz", questions=(NUMERIC,))
_start(page, slug)
question = page.locator(".quiz-question-numeric").first
question.locator("input[name='answer_text']").fill("1024")
+94
View File
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
from devplacepy.services.opinionwar import rules
def test_damage_bounds_and_monotonicity():
previous = None
for level in range(1, 200):
damage = rules.damage_for(level)
assert isinstance(damage, int)
assert rules.BASE_DAMAGE + rules.LEVEL_DAMAGE_STEP <= damage <= 300
if previous is not None:
assert damage >= previous
previous = damage
def test_damage_caps_at_level_cap():
capped = rules.damage_for(rules.LEVEL_DAMAGE_CAP)
assert rules.damage_for(rules.LEVEL_DAMAGE_CAP + 1) == capped
assert rules.damage_for(1000) == capped
assert capped == rules.BASE_DAMAGE + rules.LEVEL_DAMAGE_STEP * rules.LEVEL_DAMAGE_CAP
def test_damage_tolerates_junk_levels():
floor = rules.damage_for(1)
assert rules.damage_for(None) == floor
assert rules.damage_for(0) == floor
assert rules.damage_for(-3) == floor
assert rules.damage_for("junk") == floor
assert rules.damage_for("7") == rules.damage_for(7)
def test_leader_and_winner_agree():
for hp_a in range(0, 40, 3):
for hp_b in range(0, 40, 5):
leader = rules.leader_of(hp_a, hp_b)
winner = rules.winner_of(hp_a, hp_b)
if hp_a == hp_b:
assert leader == ""
assert winner == "draw"
else:
assert leader in ("a", "b")
assert winner == leader
def test_pct_split_sums_to_hundred():
for hp_a in range(0, 50, 7):
for hp_b in range(0, 50, 9):
pct_a, pct_b = rules.pct_split(hp_a, hp_b)
assert pct_a + pct_b == 100
assert rules.pct_split(0, 0) == (50, 50)
assert rules.pct_split(10, 0) == (100, 0)
assert rules.pct_split(0, 10) == (0, 100)
def test_ends_at_round_trip():
now = datetime.now(timezone.utc)
ends = rules.ends_at_for(now.isoformat())
assert not rules.is_ended(ends, now)
assert not rules.is_ended(ends, now + timedelta(days=6, hours=23))
assert rules.is_ended(ends, now + timedelta(days=7, seconds=1))
assert rules.is_ended("", now) is False
assert rules.is_ended("garbage", now) is False
def test_cooldown_math():
now = datetime.now(timezone.utc)
stamp = now.isoformat()
assert rules.can_fight_at("", now)
assert not rules.can_fight_at(stamp, now + timedelta(hours=23, minutes=59))
assert rules.can_fight_at(stamp, now + timedelta(hours=24, seconds=1))
ready = rules.cooldown_ready_at(stamp)
assert ready > stamp
assert rules.cooldown_ready_at("") == ""
def test_ends_in_label_shapes():
now = datetime.now(timezone.utc)
long_label = rules.ends_in_label(
(now + timedelta(days=2, hours=14, minutes=32, seconds=30)).isoformat(), now
)
assert long_label == "2d 14h 32m"
hours_label = rules.ends_in_label(
(now + timedelta(hours=3, minutes=5, seconds=30)).isoformat(), now
)
assert hours_label == "3h 5m"
minutes_label = rules.ends_in_label(
(now + timedelta(minutes=9, seconds=30)).isoformat(), now
)
assert minutes_label == "9m"
assert rules.ends_in_label((now - timedelta(minutes=1)).isoformat(), now) == ""
assert rules.ends_in_label("", now) == ""