Add Opinion Wars: week-long two-faction battles attached to posts

A new post attachment type beside polls: the composer gains a Start
Opinion War builder (same disabled-inputs opt-in as the poll builder)
that names exactly two factions; the battle runs for exactly 7 days
from post creation. Members join a side, may defect at any time
(damage already dealt stays with the faction it was dealt to), and
fight once per 24 hours per battle. A fight spends 25 Code Farm coins
and deals deterministic level-weighted damage: 100 + 10 * min(level,
20) HP, so a newcomer deals 110 and a veteran caps at 300 - no
randomness anywhere.

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 23:30:02 +02:00
co-authored by Claude Fable 5
parent 2f26dbb1e7
commit 80956ce0f4
66 changed files with 3960 additions and 1 deletions
+1
View File
@@ -46,6 +46,7 @@ Prefixes are wired in `main.py`:
| `/api` | devrant/ package - devRant-compatible REST protocol (`auth.py`, `rants.py`, `comments.py`, `notifs.py`). Translates devRant requests onto DevPlace data: rants<->posts, comments/votes onto the native engagement layer, devRant integer ids onto each table's auto-increment `id`. Token auth via `devrant_tokens`. See `routers/devrant/CLAUDE.md` for the deep detail on this tree |
| `/dbapi` | dbapi/ package - **primary-administrator-only, strictly READ-ONLY** generic database API over `dataset` (never inserts/updates/deletes/restores; the write surface was removed because it bypassed every per-route admin safeguard). `tables.py` (`GET /tables`, `GET /{table}/schema`), `crud.py` (read only: `GET /{table}` + `GET /{table}/{key}/{value}`, `?include_deleted`), `query.py` (`POST /query` hard SELECT-only validated read-only run; `POST /query/async` + `GET /query/{uid}` + `WS /query/{uid}/ws` via `DbApiJobService` kind `dbquery`), `nl.py` (`POST /nl` natural-language->SQL via the AI gateway, returns validated SELECT, `execute=true` runs it read-only). **Auth = the PRIMARY administrator only** (the earliest-created Admin, `utils.is_primary_admin` / `database.get_primary_admin_uid` - the same identity that gates backup downloads) by session/api_key (`services/dbapi/policy.py`); every other administrator is refused like a member, and there is **no internal-key path** - the gateway `internal_gateway_key()` is NOT accepted (no service-to-service access). SQL validated by `services/dbapi/validate.py` (sqlglot classify + suspicious-flag + read-only `EXPLAIN` dry-run). Devii tools `db_*` are read-only (list/get/query/nl; no write tools) and flagged `requires_primary_admin=True`, so they are added to the LLM tool list ONLY for the primary administrator (every other session never sees them and Devii is unaware they exist). `services/pubsub/policy.py` resolves its own admin/internal actor and does NOT reuse `dbapi.policy.caller_for`, so the primary-admin restriction does not leak onto the pub/sub bus. See `devplacepy/services/dbapi/CLAUDE.md` |
| `/quizzes` | quizzes/ package - **Quizzes**: `index.py` (hub, create, import, detail, export, edit, delete, publish, per-quiz leaderboard, cross-quiz scoreboard), `questions.py` (builder page + question CRUD + reorder), `attempts.py` (start/resume, play, answer, finish, results). The hub `GET /quizzes` is the approved three-column `.feed-layout` reused verbatim from `/feed`: filters + search left (`?filter=all|todo|done|mine|drafts`, `?search=`, `?page=`), the quiz list centre with the **New quiz** and **Create quiz with Devii** actions, and the cross-quiz **scoreboard** rail right (score per user, best attempt per quiz, 15s display cache). Publishing is terminal - every write on a published quiz is a 400 and there is no unpublish route. See `devplacepy/services/quiz/CLAUDE.md` |
| `/battles` | battles.py - **Opinion Wars**, week-long two-faction battles attached to posts: `GET ""` (listing, quizzes-style filters active/ended/mine + search), `GET /{uid}` (JSON-only full state, runs lazy resolution), `GET /{uid}/events?after=` (durable event trail replay), `POST /{uid}/join` (join or switch faction), `POST /{uid}/fight` (spend 25 Code Farm coins, deal level-weighted damage, 24h cooldown). The battle card renders on the parent post (no battle detail HTML page). See `devplacepy/services/opinionwar/CLAUDE.md` |
| `/game` | game/ package - the **Code Farm** idle game (`index.py` + `farm.py`): `GET /game` (page), `GET /game/state`, `GET /game/leaderboard?board=`, `POST /game/{plant,harvest,buy-plot,upgrade,fertilize,daily,grant,perk,prestige,legacy,mastery,quests/claim}`, `POST /game/{defense/upgrade,defense/downgrade,infrastructure/buy,cosmetics/buy,cosmetics/equip}`, plus social `GET /game/farm/{username}` and `POST /game/farm/{username}/{water,steal}`. See `devplacepy/services/game/CLAUDE.md` |
| (none) | push.py - push + PWA: `GET /push.json` (VAPID public key + the providers accepting registrations), `POST /push.json` (register with any active provider; a body without `provider` is a `webpush` body, so existing clients are unchanged; an unknown, disabled or unconfigured provider is a 400), `GET /service-worker.js`, `GET /manifest.json`. Provider protocol and delivery live in `devplacepy/push/` - see `devplacepy/push/CLAUDE.md` |
| (none) | docs.py (docs/ package) - the documentation site (prose pages + API reference). See `routers/docs/CLAUDE.md` for the deep detail on this tree (`DOCS_PAGES`, audience tiers, prose rendering pipeline) |
+129
View File
@@ -0,0 +1,129 @@
# retoor <retoor@molodetz.nl>
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from devplacepy.database import resolve_object_url
from devplacepy.dependencies import json_or_form
from devplacepy.models import WarJoinForm
from devplacepy.responses import action_result, json_error, respond, wants_json
from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut
from devplacepy.seo import base_seo_context
from devplacepy.services.opinionwar import WarError, rules, store
from devplacepy.utils import get_current_user, not_found, require_user
router = APIRouter()
def _war_error(request: Request, message: str, redirect_url: str):
if wants_json(request):
return json_error(400, message)
separator = "&" if "?" in redirect_url else "?"
return RedirectResponse(
url=f"{redirect_url}{separator}error={quote(message)}", status_code=302
)
def _post_url(war: dict) -> str:
return resolve_object_url("post", war["post_uid"])
@router.get("", response_class=HTMLResponse)
async def battles_page(
request: Request, filter: str = "active", search: str = "", page: int = 1
):
user = get_current_user(request)
current_filter = filter if filter in store.FILTERS else "active"
battles, pagination = store.list_wars(
viewer=user,
war_filter=current_filter,
search=search,
page=max(1, page),
)
seo_ctx = base_seo_context(
request,
title="Opinion Wars",
description=(
"Week-long faction battles between developers. Pick a side, fight once "
"a day and carry your faction to victory."
),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Battles", "url": "/battles"},
],
)
return respond(
request,
"battles.html",
{
**seo_ctx,
"user": user,
"battles": battles,
"current_filter": current_filter,
"counts": store.filter_counts(user, search),
"search": search,
"pagination": pagination,
},
model=BattlesOut,
)
@router.get("/{war_uid}")
async def battle_state(request: Request, war_uid: str):
user = get_current_user(request)
serialized = store.get_war_serialized(store.get_war(war_uid), user)
if not serialized:
raise not_found("Battle not found")
return JSONResponse(WarOut.model_validate(serialized).model_dump())
@router.get("/{war_uid}/events")
async def battle_events(
request: Request, war_uid: str, after: int = 0, limit: int = rules.EVENT_LIMIT_DEFAULT
):
war = store.resolve_if_due(store.get_war(war_uid))
if not war:
raise not_found("Battle not found")
events = store.events_for(war_uid, after_seq=after, limit=limit)
return JSONResponse(
WarEventsOut.model_validate(
{"events": events, "status": war.get("status") or "active"}
).model_dump()
)
@router.post("/{war_uid}/join")
async def join_battle(
request: Request,
war_uid: str,
data: Annotated[WarJoinForm, Depends(json_or_form(WarJoinForm))],
):
user = require_user(request)
war = store.get_war(war_uid)
if not war:
raise not_found("Battle not found")
url = _post_url(war)
try:
war = store.join_war(war, user, data.faction, request)
except WarError as exc:
return _war_error(request, str(exc), url)
serialized = store.get_war_serialized(war, user)
return action_result(request, url, data={"war": serialized})
@router.post("/{war_uid}/fight")
async def fight_battle(request: Request, war_uid: str):
user = require_user(request)
war = store.get_war(war_uid)
if not war:
raise not_found("Battle not found")
url = _post_url(war)
try:
war, damage = store.fight(war, user, request)
except WarError as exc:
return _war_error(request, str(exc), url)
serialized = store.get_war_serialized(war, user)
return action_result(request, url, data={"war": serialized, "damage": damage})
+6
View File
@@ -82,6 +82,12 @@ DOCS_PAGES = [
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "opinion-wars",
"title": "Opinion Wars",
"kind": "prose",
"section": SECTION_GENERAL,
},
{
"slug": "block-and-mute",
"title": "Block and mute",
+5
View File
@@ -14,6 +14,9 @@ from devplacepy.database import (
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
)
from devplacepy.services.opinionwar import store as war_store
from devplacepy.database import (
paginate_diverse,
text_search_clause,
)
@@ -101,6 +104,7 @@ async def feed_page(
get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids_list, user)
wars_map = war_store.get_wars_by_post_uids(post_uids_list, user)
for item in posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
@@ -108,6 +112,7 @@ async def feed_page(
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
item["war"] = wars_map.get(uid)
seo_ctx = list_page_seo(
request,
+2
View File
@@ -41,6 +41,7 @@ from devplacepy.seo import (
from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm
from devplacepy.services.audit import record as audit
from devplacepy.services.opinionwar import store as war_store
from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
@@ -87,6 +88,7 @@ async def create_post(request: Request, data: Annotated[PostForm, Depends(json_o
)
create_poll(uid, user, data.poll_question, data.poll_options, request)
war_store.create_war(uid, user, data.war_faction_a, data.war_faction_b, request)
url = f"/posts/{post_slug}"
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
+5
View File
@@ -17,6 +17,9 @@ from devplacepy.database import (
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
)
from devplacepy.services.opinionwar import store as war_store
from devplacepy.database import (
get_activity_heatmap,
get_activity_months,
get_streaks,
@@ -203,11 +206,13 @@ async def profile_page(
else set()
)
polls_map = get_polls_by_post_uids(post_uids, current_user)
wars_map = war_store.get_wars_by_post_uids(post_uids, current_user)
for item in posts:
uid = item["post"]["uid"]
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
item["war"] = wars_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
for b in badges:
+5
View File
@@ -23,6 +23,9 @@ from devplacepy.database import (
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
)
from devplacepy.services.opinionwar import store as war_store
from devplacepy.database import (
paginate,
text_search_clause,
resolve_by_slug,
@@ -289,12 +292,14 @@ async def project_detail(request: Request, project_slug: str, before: str = None
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids, user)
wars_map = war_store.get_wars_by_post_uids(post_uids, user)
for item in devlog_posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
item["war"] = wars_map.get(uid)
return respond(
request,