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:
@@ -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) |
|
||||
|
||||
@@ -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})
|
||||
@@ -82,6 +82,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "opinion-wars",
|
||||
"title": "Opinion Wars",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "block-and-mute",
|
||||
"title": "Block and mute",
|
||||
|
||||
@@ -14,6 +14,9 @@ from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
)
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.database import (
|
||||
paginate_diverse,
|
||||
text_search_clause,
|
||||
)
|
||||
@@ -101,6 +104,7 @@ async def feed_page(
|
||||
get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids_list, user)
|
||||
wars_map = war_store.get_wars_by_post_uids(post_uids_list, user)
|
||||
for item in posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
@@ -108,6 +112,7 @@ async def feed_page(
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
item["war"] = wars_map.get(uid)
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
|
||||
@@ -41,6 +41,7 @@ from devplacepy.seo import (
|
||||
from devplacepy.attachments import save_inline_image
|
||||
from devplacepy.models import PostForm, PostEditForm
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.dependencies import json_or_form
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -87,6 +88,7 @@ async def create_post(request: Request, data: Annotated[PostForm, Depends(json_o
|
||||
)
|
||||
|
||||
create_poll(uid, user, data.poll_question, data.poll_options, request)
|
||||
war_store.create_war(uid, user, data.war_faction_a, data.war_faction_b, request)
|
||||
url = f"/posts/{post_slug}"
|
||||
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
)
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.database import (
|
||||
get_activity_heatmap,
|
||||
get_activity_months,
|
||||
get_streaks,
|
||||
@@ -203,11 +206,13 @@ async def profile_page(
|
||||
else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids, current_user)
|
||||
wars_map = war_store.get_wars_by_post_uids(post_uids, current_user)
|
||||
for item in posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
item["war"] = wars_map.get(uid)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
for b in badges:
|
||||
|
||||
@@ -23,6 +23,9 @@ from devplacepy.database import (
|
||||
get_reactions_by_targets,
|
||||
get_user_bookmarks,
|
||||
get_polls_by_post_uids,
|
||||
)
|
||||
from devplacepy.services.opinionwar import store as war_store
|
||||
from devplacepy.database import (
|
||||
paginate,
|
||||
text_search_clause,
|
||||
resolve_by_slug,
|
||||
@@ -289,12 +292,14 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
||||
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
||||
)
|
||||
polls_map = get_polls_by_post_uids(post_uids, user)
|
||||
wars_map = war_store.get_wars_by_post_uids(post_uids, user)
|
||||
for item in devlog_posts:
|
||||
uid = item["post"]["uid"]
|
||||
item["attachments"] = attachments_map.get(uid, [])
|
||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||
item["bookmarked"] = uid in bookmark_set
|
||||
item["poll"] = polls_map.get(uid)
|
||||
item["war"] = wars_map.get(uid)
|
||||
|
||||
return respond(
|
||||
request,
|
||||
|
||||
Reference in New Issue
Block a user