From 80956ce0f408a4cd6051c581a8fcf685820a11f3 Mon Sep 17 00:00:00 2001 From: blindxfish Date: Thu, 20 Aug 2026 23:30:02 +0200 Subject: [PATCH 1/2] Add Opinion Wars: week-long two-faction battles attached to posts A new post attachment type beside polls: the composer gains a Start Opinion War builder (same disabled-inputs opt-in as the poll builder) that names exactly two factions; the battle runs for exactly 7 days from post creation. Members join a side, may defect at any time (damage already dealt stays with the faction it was dealt to), and fight once per 24 hours per battle. A fight spends 25 Code Farm coins and deals deterministic level-weighted damage: 100 + 10 * min(level, 20) HP, so a newcomer deals 110 and a veteran caps at 300 - no randomness anywhere. The battle renders on the post card as a CSS pixel-art battlefield (box-shadow sprites: castles, faction flags, marching soldiers, a flickering campfire; steps() animation, disabled under reduced motion) with live HP bars, a countdown, the viewer's faction strip, top contributors and an event ticker. Live frames ride pub/sub on public.battle.{uid} via a relay on the service-lock owner, with the durable opinion_war_events trail (per-war atomic seq) as the source of truth and a 15s incremental poller as fallback. /battles lists battles with active/ended/mine filters, search and pagination. Every mutation is a conditional UPDATE via conditional_update_row: the fight sequence claims the cooldown first, then spends coins, then lands the damage, compensating earlier steps on any later refusal so a crash costs a turn, never coins. Resolution is lazy on read (no cron): an exactly-once CAS computes the winner in the statement, awards XP (participation, winner bonus, top damage dealer bonus; draws pay participation only), emits the result event and notifies fighters. The OpinionWarService backstop resolves unviewed wars and sends fight-ready notifications, exactly-once via a marker CAS. Fan-out: battle notification type, four badges, audit keys (battle.create/join/switch/fight/resolve), Devii actions (join/fight confirm-gated), API docs group, docs prose page, sitemap and topnav entries, REPORTABLE_TARGETS registration, post-delete cascades, README and nested CLAUDE.md documentation. Verified with the four-layer procedure: property checks over the full damage domain, 1200-step stateful fuzz (hp-sum invariant, coins never negative, resolved totals frozen), and real 8-process races proving exactly-once semantics for concurrent fights, double-spends across two wars, resolution XP and double-joins. Persisted tests in tests/unit/services/opinionwar, tests/api/battles, tests/e2e/battles and tests/api/posts/create.py. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 + README.md | 20 + devplacepy/config.py | 2 + devplacepy/content.py | 5 + devplacepy/database/content.py | 5 + devplacepy/database/moderation.py | 3 + devplacepy/database/notifications.py | 1 + devplacepy/database/ranking.py | 18 + devplacepy/database/schema.py | 92 ++ devplacepy/database/soft_delete.py | 3 + devplacepy/docs_api/groups/__init__.py | 2 + devplacepy/docs_api/groups/battles.py | 149 ++++ devplacepy/docs_api/groups/content.py | 16 + devplacepy/main.py | 4 + devplacepy/models.py | 13 + devplacepy/routers/CLAUDE.md | 1 + devplacepy/routers/battles.py | 129 +++ devplacepy/routers/docs/pages.py | 6 + devplacepy/routers/feed.py | 5 + devplacepy/routers/posts.py | 2 + devplacepy/routers/profile/index.py | 5 + devplacepy/routers/projects/index.py | 5 + devplacepy/schemas/__init__.py | 9 + devplacepy/schemas/battles.py | 82 ++ devplacepy/schemas/listings.py | 3 + devplacepy/seo.py | 1 + devplacepy/services/audit/CLAUDE.md | 2 +- devplacepy/services/audit/categories.py | 1 + .../devii/actions/catalog/__init__.py | 2 + .../services/devii/actions/catalog/battles.py | 85 ++ .../services/devii/actions/catalog/posts.py | 8 + .../services/devii/actions/dispatcher.py | 2 + devplacepy/services/opinionwar/CLAUDE.md | 134 +++ devplacepy/services/opinionwar/__init__.py | 6 + devplacepy/services/opinionwar/rules.py | 111 +++ devplacepy/services/opinionwar/service.py | 90 ++ devplacepy/services/opinionwar/store.py | 794 ++++++++++++++++++ devplacepy/static/css/opinionwar.css | 714 ++++++++++++++++ devplacepy/static/js/Application.js | 2 + devplacepy/static/js/WarComposer.js | 106 +++ .../static/js/components/AppOpinionWar.js | 254 ++++++ devplacepy/static/js/components/index.js | 1 + devplacepy/templates/_opinion_war.html | 95 +++ devplacepy/templates/_opinion_war_field.html | 14 + devplacepy/templates/_post_card.html | 4 + devplacepy/templates/_post_composer_form.html | 13 + devplacepy/templates/base.html | 3 + devplacepy/templates/battles.html | 53 ++ devplacepy/templates/docs/opinion-wars.html | 51 ++ devplacepy/templates/post.html | 4 + devplacepy/utils/CLAUDE.md | 1 + devplacepy/utils/__init__.py | 3 + devplacepy/utils/badges.py | 4 + devplacepy/utils/rewards.py | 6 + events.md | 5 + tests/api/battles/__init__.py | 0 tests/api/battles/_helpers.py | 78 ++ tests/api/battles/actions.py | 165 ++++ tests/api/battles/events.py | 43 + tests/api/battles/index.py | 73 ++ tests/api/battles/resolve.py | 94 +++ tests/api/posts/create.py | 106 +++ tests/e2e/battles/__init__.py | 0 tests/e2e/battles/index.py | 157 ++++ tests/unit/services/opinionwar/__init__.py | 0 tests/unit/services/opinionwar/rules.py | 94 +++ 66 files changed, 3960 insertions(+), 1 deletion(-) create mode 100644 devplacepy/docs_api/groups/battles.py create mode 100644 devplacepy/routers/battles.py create mode 100644 devplacepy/schemas/battles.py create mode 100644 devplacepy/services/devii/actions/catalog/battles.py create mode 100644 devplacepy/services/opinionwar/CLAUDE.md create mode 100644 devplacepy/services/opinionwar/__init__.py create mode 100644 devplacepy/services/opinionwar/rules.py create mode 100644 devplacepy/services/opinionwar/service.py create mode 100644 devplacepy/services/opinionwar/store.py create mode 100644 devplacepy/static/css/opinionwar.css create mode 100644 devplacepy/static/js/WarComposer.js create mode 100644 devplacepy/static/js/components/AppOpinionWar.js create mode 100644 devplacepy/templates/_opinion_war.html create mode 100644 devplacepy/templates/_opinion_war_field.html create mode 100644 devplacepy/templates/battles.html create mode 100644 devplacepy/templates/docs/opinion-wars.html create mode 100644 tests/api/battles/__init__.py create mode 100644 tests/api/battles/_helpers.py create mode 100644 tests/api/battles/actions.py create mode 100644 tests/api/battles/events.py create mode 100644 tests/api/battles/index.py create mode 100644 tests/api/battles/resolve.py create mode 100644 tests/e2e/battles/__init__.py create mode 100644 tests/e2e/battles/index.py create mode 100644 tests/unit/services/opinionwar/__init__.py create mode 100644 tests/unit/services/opinionwar/rules.py diff --git a/CLAUDE.md b/CLAUDE.md index 608f7642..203a7e6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/README.md b/README.md index c555d9e6..9c59830d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/devplacepy/config.py b/devplacepy/config.py index 8f93c00a..e335fe46 100644 --- a/devplacepy/config.py +++ b/devplacepy/config.py @@ -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`" diff --git a/devplacepy/content.py b/devplacepy/content.py index 597f9e65..81edad85 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -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"], } diff --git a/devplacepy/database/content.py b/devplacepy/database/content.py index 50bfea27..63b7160f 100644 --- a/devplacepy/database/content.py +++ b/devplacepy/database/content.py @@ -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" diff --git a/devplacepy/database/moderation.py b/devplacepy/database/moderation.py index 7ee64859..7a9dcdc5 100644 --- a/devplacepy/database/moderation.py +++ b/devplacepy/database/moderation.py @@ -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", diff --git a/devplacepy/database/notifications.py b/devplacepy/database/notifications.py index 11422486..3496005f 100644 --- a/devplacepy/database/notifications.py +++ b/devplacepy/database/notifications.py @@ -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)"}, diff --git a/devplacepy/database/ranking.py b/devplacepy/database/ranking.py index 3c4a4ef0..dc22acb8 100644 --- a/devplacepy/database/ranking.py +++ b/devplacepy/database/ranking.py @@ -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: diff --git a/devplacepy/database/schema.py b/devplacepy/database/schema.py index e34746da..180d408f 100644 --- a/devplacepy/database/schema.py +++ b/devplacepy/database/schema.py @@ -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, diff --git a/devplacepy/database/soft_delete.py b/devplacepy/database/soft_delete.py index 6c132cd3..b345c08a 100644 --- a/devplacepy/database/soft_delete.py +++ b/devplacepy/database/soft_delete.py @@ -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", diff --git a/devplacepy/docs_api/groups/__init__.py b/devplacepy/docs_api/groups/__init__.py index 5c903a85..17543982 100644 --- a/devplacepy/docs_api/groups/__init__.py +++ b/devplacepy/docs_api/groups/__init__.py @@ -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, ] diff --git a/devplacepy/docs_api/groups/battles.py b/devplacepy/docs_api/groups/battles.py new file mode 100644 index 00000000..bd50f575 --- /dev/null +++ b/devplacepy/docs_api/groups/battles.py @@ -0,0 +1,149 @@ +# retoor + +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", "body", "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}}, + ), + ], +} diff --git a/devplacepy/docs_api/groups/content.py b/devplacepy/docs_api/groups/content.py index 61cc1680..80cb84c8 100644 --- a/devplacepy/docs_api/groups/content.py +++ b/devplacepy/docs_api/groups/content.py @@ -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."], ), diff --git a/devplacepy/main.py b/devplacepy/main.py index a3a5a104..56c82c67 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -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") diff --git a/devplacepy/models.py b/devplacepy/models.py index 4417998a..46d75cfe 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -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) diff --git a/devplacepy/routers/CLAUDE.md b/devplacepy/routers/CLAUDE.md index 344f29ca..a9afc17e 100644 --- a/devplacepy/routers/CLAUDE.md +++ b/devplacepy/routers/CLAUDE.md @@ -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) | diff --git a/devplacepy/routers/battles.py b/devplacepy/routers/battles.py new file mode 100644 index 00000000..f2ba5fb2 --- /dev/null +++ b/devplacepy/routers/battles.py @@ -0,0 +1,129 @@ +# retoor + +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}) diff --git a/devplacepy/routers/docs/pages.py b/devplacepy/routers/docs/pages.py index f87b51d2..5e2a2912 100644 --- a/devplacepy/routers/docs/pages.py +++ b/devplacepy/routers/docs/pages.py @@ -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", diff --git a/devplacepy/routers/feed.py b/devplacepy/routers/feed.py index 0ee97b1a..7a068cfd 100644 --- a/devplacepy/routers/feed.py +++ b/devplacepy/routers/feed.py @@ -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, diff --git a/devplacepy/routers/posts.py b/devplacepy/routers/posts.py index 5c033f04..90c19096 100644 --- a/devplacepy/routers/posts.py +++ b/devplacepy/routers/posts.py @@ -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}) diff --git a/devplacepy/routers/profile/index.py b/devplacepy/routers/profile/index.py index e6d8c11e..708a0709 100644 --- a/devplacepy/routers/profile/index.py +++ b/devplacepy/routers/profile/index.py @@ -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: diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py index bc516617..5f63709c 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -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, diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index 4f84d164..0e361184 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -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, diff --git a/devplacepy/schemas/battles.py b/devplacepy/schemas/battles.py new file mode 100644 index 00000000..0c5d75ee --- /dev/null +++ b/devplacepy/schemas/battles.py @@ -0,0 +1,82 @@ +# retoor + +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 = "" diff --git a/devplacepy/schemas/listings.py b/devplacepy/schemas/listings.py index dcc0d44f..b1ed1841 100644 --- a/devplacepy/schemas/listings.py +++ b/devplacepy/schemas/listings.py @@ -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] = [] diff --git a/devplacepy/seo.py b/devplacepy/seo.py index 5c4f32d1..2f1c4f16 100644 --- a/devplacepy/seo.py +++ b/devplacepy/seo.py @@ -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") ) diff --git a/devplacepy/services/audit/CLAUDE.md b/devplacepy/services/audit/CLAUDE.md index 2f5b4ba2..162a1863 100644 --- a/devplacepy/services/audit/CLAUDE.md +++ b/devplacepy/services/audit/CLAUDE.md @@ -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 diff --git a/devplacepy/services/audit/categories.py b/devplacepy/services/audit/categories.py index 4ae6020c..da787a06 100644 --- a/devplacepy/services/audit/categories.py +++ b/devplacepy/services/audit/categories.py @@ -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", diff --git a/devplacepy/services/devii/actions/catalog/__init__.py b/devplacepy/services/devii/actions/catalog/__init__.py index 5994d8b3..305d66fb 100644 --- a/devplacepy/services/devii/actions/catalog/__init__.py +++ b/devplacepy/services/devii/actions/catalog/__init__.py @@ -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 ) diff --git a/devplacepy/services/devii/actions/catalog/battles.py b/devplacepy/services/devii/actions/catalog/battles.py new file mode 100644 index 00000000..0e7f583b --- /dev/null +++ b/devplacepy/services/devii/actions/catalog/battles.py @@ -0,0 +1,85 @@ +# retoor + +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()), + ), +) diff --git a/devplacepy/services/devii/actions/catalog/posts.py b/devplacepy/services/devii/actions/catalog/posts.py index ea32e4c7..d2fd87ee 100644 --- a/devplacepy/services/devii/actions/catalog/posts.py +++ b/devplacepy/services/devii/actions/catalog/posts.py @@ -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( diff --git a/devplacepy/services/devii/actions/dispatcher.py b/devplacepy/services/devii/actions/dispatcher.py index 54ec706c..e6d09250 100644 --- a/devplacepy/services/devii/actions/dispatcher.py +++ b/devplacepy/services/devii/actions/dispatcher.py @@ -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", diff --git a/devplacepy/services/opinionwar/CLAUDE.md b/devplacepy/services/opinionwar/CLAUDE.md new file mode 100644 index 00000000..6ab5d504 --- /dev/null +++ b/devplacepy/services/opinionwar/CLAUDE.md @@ -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/`. diff --git a/devplacepy/services/opinionwar/__init__.py b/devplacepy/services/opinionwar/__init__.py new file mode 100644 index 00000000..9f103413 --- /dev/null +++ b/devplacepy/services/opinionwar/__init__.py @@ -0,0 +1,6 @@ +# retoor + +from . import rules, store +from .store import WarError + +__all__ = ["rules", "store", "WarError"] diff --git a/devplacepy/services/opinionwar/rules.py b/devplacepy/services/opinionwar/rules.py new file mode 100644 index 00000000..a5d7feda --- /dev/null +++ b/devplacepy/services/opinionwar/rules.py @@ -0,0 +1,111 @@ +# retoor + +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" diff --git a/devplacepy/services/opinionwar/service.py b/devplacepy/services/opinionwar/service.py new file mode 100644 index 00000000..c8da5e05 --- /dev/null +++ b/devplacepy/services/opinionwar/service.py @@ -0,0 +1,90 @@ +# retoor + +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} diff --git a/devplacepy/services/opinionwar/store.py b/devplacepy/services/opinionwar/store.py new file mode 100644 index 00000000..141db9b8 --- /dev/null +++ b/devplacepy/services/opinionwar/store.py @@ -0,0 +1,794 @@ +# retoor + +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 diff --git a/devplacepy/static/css/opinionwar.css b/devplacepy/static/css/opinionwar.css new file mode 100644 index 00000000..25754a9e --- /dev/null +++ b/devplacepy/static/css/opinionwar.css @@ -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; + } +} diff --git a/devplacepy/static/js/Application.js b/devplacepy/static/js/Application.js index 228382b6..4c7744b7 100644 --- a/devplacepy/static/js/Application.js +++ b/devplacepy/static/js/Application.js @@ -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(); diff --git a/devplacepy/static/js/WarComposer.js b/devplacepy/static/js/WarComposer.js new file mode 100644 index 00000000..4bc6d760 --- /dev/null +++ b/devplacepy/static/js/WarComposer.js @@ -0,0 +1,106 @@ +// retoor + +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; + } + } + } +} diff --git a/devplacepy/static/js/components/AppOpinionWar.js b/devplacepy/static/js/components/AppOpinionWar.js new file mode 100644 index 00000000..439e6c6b --- /dev/null +++ b/devplacepy/static/js/components/AppOpinionWar.js @@ -0,0 +1,254 @@ +// retoor + +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); diff --git a/devplacepy/static/js/components/index.js b/devplacepy/static/js/components/index.js index c292254e..ccea1dd1 100644 --- a/devplacepy/static/js/components/index.js +++ b/devplacepy/static/js/components/index.js @@ -15,4 +15,5 @@ import "./AppIsslopRun.js"; import "./AppChat.js"; import "./AppQuizPlayer.js"; import "./AppQuizBuilder.js"; +import "./AppOpinionWar.js"; import "./ContainerTerminal.js"; diff --git a/devplacepy/templates/_opinion_war.html b/devplacepy/templates/_opinion_war.html new file mode 100644 index 00000000..f81ec3e7 --- /dev/null +++ b/devplacepy/templates/_opinion_war.html @@ -0,0 +1,95 @@ + +
+ Opinion War + {% if _war.status == 'active' %}Battle ends in {{ _war.ends_in }}{% else %}Battle ended{% endif %} +
+ +
+
+ {{ render_title(_war.faction_a) }} +
{{ _war.pct_a }}%
+ {{ "{:,}".format(_war.hp_a) }} HP +
+ +
+ {{ render_title(_war.faction_b) }} +
{{ _war.pct_b }}%
+ {{ "{:,}".format(_war.hp_b) }} HP +
+
+ + {% include "_opinion_war_field.html" %} + +
+ + {%- 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 -%} + +
+ + {% if _war.viewer %} +
+ {{ render_title(_war.viewer.faction == 'a' and _war.faction_a or _war.faction_b) }} + Rank #{{ _war.viewer.rank }} · {{ "{:,}".format(_war.viewer.hp) }} HP dealt +
+ {% endif %} + + {% if _war.status == 'active' %} +
+ {% if not _war.viewer %} +
+ + +
+
+ + +
+ {% else %} +
+ +
+
+ + +
+ {% endif %} +
+ {% endif %} + + {% if _war.top_contributors %} +
+ Top contributors +
    + {% for fighter in _war.top_contributors %} +
  • + {% set _user = fighter %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} + {% set _user = fighter %}{% include "_user_link.html" %} + {{ "{:,}".format(fighter.hp) }} HP +
  • + {% endfor %} +
+
+ {% endif %} + +
+ Live events +
    + {% for e in _war.recent_events %} +
  • {{ e.message }}{{ dt_ago(e.created_at) }}
  • + {% else %} +
  • No battle activity yet - be the first to fight!
  • + {% endfor %} +
+
+ +
+ View All Battles + {% 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" %} +
+
diff --git a/devplacepy/templates/_opinion_war_field.html b/devplacepy/templates/_opinion_war_field.html new file mode 100644 index 00000000..9ef255d8 --- /dev/null +++ b/devplacepy/templates/_opinion_war_field.html @@ -0,0 +1,14 @@ +{% set _strength_a = 1 + (_war.pct_a // 30) %} +{% set _strength_b = 1 + (_war.pct_b // 30) %} + diff --git a/devplacepy/templates/_post_card.html b/devplacepy/templates/_post_card.html index a419d55c..87cf9a26 100644 --- a/devplacepy/templates/_post_card.html +++ b/devplacepy/templates/_post_card.html @@ -34,6 +34,10 @@ {% set _poll = item.poll %}{% include "_poll.html" %} {% endif %} + {% if item.war %} + {% set _war = item.war %}{% include "_opinion_war.html" %} + {% endif %} +
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %} diff --git a/devplacepy/templates/_post_composer_form.html b/devplacepy/templates/_post_composer_form.html index c29943ff..e4bb0338 100644 --- a/devplacepy/templates/_post_composer_form.html +++ b/devplacepy/templates/_post_composer_form.html @@ -41,6 +41,19 @@
+
+ +
+ +
@@ -144,6 +146,7 @@ 📄 Gists 🚀 Projects 🧩 Quizzes + ⚔︎ Battles 🏆 Leaderboard {% if user %}🌱 Farm{% endif %}
diff --git a/devplacepy/templates/battles.html b/devplacepy/templates/battles.html new file mode 100644 index 00000000..8cced3a0 --- /dev/null +++ b/devplacepy/templates/battles.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block extra_head %} + + +{% endblock %} +{% block content %} +

Opinion Wars

+
+ + +
+
+ {% for battle in battles %} + + {% else %} +
No battles here yet. Start an Opinion War from the Create New Post form.
+ {% endfor %} +
+ + {% set pagination_query = "filter=" ~ current_filter ~ "&" ~ ("search=" ~ search ~ "&" if search else "") %} + {% include "_pagination.html" %} +
+
+{% endblock %} diff --git a/devplacepy/templates/docs/opinion-wars.html b/devplacepy/templates/docs/opinion-wars.html new file mode 100644 index 00000000..1198604b --- /dev/null +++ b/devplacepy/templates/docs/opinion-wars.html @@ -0,0 +1,51 @@ +
+# 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. +
diff --git a/devplacepy/templates/post.html b/devplacepy/templates/post.html index 034a0dbd..1ddb8551 100644 --- a/devplacepy/templates/post.html +++ b/devplacepy/templates/post.html @@ -44,6 +44,10 @@ {% set _poll = poll %}{% include "_poll.html" %} {% endif %} + {% if war %} + {% set _war = war %}{% include "_opinion_war.html" %} + {% endif %} +
{% 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" %} diff --git a/devplacepy/utils/CLAUDE.md b/devplacepy/utils/CLAUDE.md index c4db2977..2afc289d 100644 --- a/devplacepy/utils/CLAUDE.md +++ b/devplacepy/utils/CLAUDE.md @@ -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. diff --git a/devplacepy/utils/__init__.py b/devplacepy/utils/__init__.py index 3310caa7..b4ef279f 100644 --- a/devplacepy/utils/__init__.py +++ b/devplacepy/utils/__init__.py @@ -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, diff --git a/devplacepy/utils/badges.py b/devplacepy/utils/badges.py index 776532a5..db7657f0 100644 --- a/devplacepy/utils/badges.py +++ b/devplacepy/utils/badges.py @@ -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"}, diff --git a/devplacepy/utils/rewards.py b/devplacepy/utils/rewards.py index 34f06bb2..5d765a88 100644 --- a/devplacepy/utils/rewards.py +++ b/devplacepy/utils/rewards.py @@ -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")], diff --git a/events.md b/events.md index c57f2616..89dc5488 100644 --- a/events.md +++ b/events.md @@ -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` | diff --git a/tests/api/battles/__init__.py b/tests/api/battles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/battles/_helpers.py b/tests/api/battles/_helpers.py new file mode 100644 index 00000000..b4c1d956 --- /dev/null +++ b/tests/api/battles/_helpers.py @@ -0,0 +1,78 @@ +# retoor + +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) diff --git a/tests/api/battles/actions.py b/tests/api/battles/actions.py new file mode 100644 index 00000000..067159b9 --- /dev/null +++ b/tests/api/battles/actions.py @@ -0,0 +1,165 @@ +# retoor + +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 diff --git a/tests/api/battles/events.py b/tests/api/battles/events.py new file mode 100644 index 00000000..ecfc4121 --- /dev/null +++ b/tests/api/battles/events.py @@ -0,0 +1,43 @@ +# retoor + +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 diff --git a/tests/api/battles/index.py b/tests/api/battles/index.py new file mode 100644 index 00000000..221f646c --- /dev/null +++ b/tests/api/battles/index.py @@ -0,0 +1,73 @@ +# retoor + +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 diff --git a/tests/api/battles/resolve.py b/tests/api/battles/resolve.py new file mode 100644 index 00000000..87bba63f --- /dev/null +++ b/tests/api/battles/resolve.py @@ -0,0 +1,94 @@ +# retoor + +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" diff --git a/tests/api/posts/create.py b/tests/api/posts/create.py index 464437bc..751b551b 100644 --- a/tests/api/posts/create.py +++ b/tests/api/posts/create.py @@ -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 diff --git a/tests/e2e/battles/__init__.py b/tests/e2e/battles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/battles/index.py b/tests/e2e/battles/index.py new file mode 100644 index 00000000..6a998d4b --- /dev/null +++ b/tests/e2e/battles/index.py @@ -0,0 +1,157 @@ +# retoor + +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() + ticker_first = card.locator("[data-war-ticker] .war-event").first + expect(ticker_first).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() diff --git a/tests/unit/services/opinionwar/__init__.py b/tests/unit/services/opinionwar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/services/opinionwar/rules.py b/tests/unit/services/opinionwar/rules.py new file mode 100644 index 00000000..ea02842f --- /dev/null +++ b/tests/unit/services/opinionwar/rules.py @@ -0,0 +1,94 @@ +# retoor + +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) == "" -- 2.54.0 From 50baf9d6f1f7d53962abdf00d5440876af20968e Mon Sep 17 00:00:00 2001 From: blindxfish Date: Fri, 21 Aug 2026 00:05:00 +0200 Subject: [PATCH 2/2] Fix battles join docs param location and the fight-flow ticker assertion The auth matrix probes documented endpoints with their documented form params; the faction param was declared with location body instead of form, so the anonymous probe sent an empty body and hit 422 validation before the auth guard. And the first fight always triggers a lead-change event that outranks the fight event at the top of the ticker, so the e2e flow now asserts on the ticker as a whole. Co-Authored-By: Claude Fable 5 --- devplacepy/docs_api/groups/battles.py | 2 +- tests/e2e/battles/index.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/devplacepy/docs_api/groups/battles.py b/devplacepy/docs_api/groups/battles.py index bd50f575..b053579c 100644 --- a/devplacepy/docs_api/groups/battles.py +++ b/devplacepy/docs_api/groups/battles.py @@ -125,7 +125,7 @@ coins, ended battle) answers `400` as `{{"error": {{"status": 400, "message": ". encoding="form", params=[ field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."), - field("faction", "body", "enum", True, "a", "Which side to join or switch to.", options=["a", "b"]), + 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}}, ), diff --git a/tests/e2e/battles/index.py b/tests/e2e/battles/index.py index 6a998d4b..2f353345 100644 --- a/tests/e2e/battles/index.py +++ b/tests/e2e/battles/index.py @@ -126,8 +126,7 @@ def test_create_join_and_fight_flow(alice): arg=hp_before, ) expect(card.locator("[data-war-fight]")).to_be_disabled() - ticker_first = card.locator("[data-war-ticker] .war-event").first - expect(ticker_first).to_contain_text("dealt") + expect(card.locator("[data-war-ticker]")).to_contain_text("dealt") def test_switch_faction_shows_confirm(alice): -- 2.54.0