Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
055c7bcd07 | ||
|
|
50baf9d6f1 | ||
|
|
80956ce0f4 | ||
|
|
2f26dbb1e7 | ||
|
|
516219513a | ||
|
|
8856c38b4d | ||
|
|
08d370b020 | ||
|
|
076f55f380 | ||
|
|
e0672f896d | ||
|
|
c9802440d7 | ||
|
|
15f04e0d13 | ||
|
|
551d540bc7 | ||
|
|
fd409ceea7 |
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`"
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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}},
|
||||
),
|
||||
],
|
||||
}
|
||||
@@ -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."],
|
||||
),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" 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="<Path>" 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="<Group>">
|
||||
<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 |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,4 +8,10 @@
|
||||
<a href="/docs/privacy.html"><span class="icon">🔒</span> Privacy</a>
|
||||
<a href="/docs/community-guidelines.html"><span class="icon">🤝</span> Guidelines</a>
|
||||
<a href="/docs/contact.html"><span class="icon">✉️</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>
|
||||
|
||||
@@ -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">⚔︎</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 }} · <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">⚔︎</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>
|
||||
@@ -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">⚔️</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>
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
<small>Public origin used for absolute links (SEO, container ingress /p/<slug>). 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>
|
||||
|
||||
@@ -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">⚔︎</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">⚔︎</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>
|
||||
|
||||
@@ -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">⚔︎</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">🏆</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">🛡︎</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" %} · {{ 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) == ""
|
||||
Reference in New Issue
Block a user