From 80956ce0f408a4cd6051c581a8fcf685820a11f3 Mon Sep 17 00:00:00 2001 From: blindxfish Date: Thu, 20 Aug 2026 23:30:02 +0200 Subject: [PATCH] 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) == ""