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
View File
+78
View File
@@ -0,0 +1,78 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from devplacepy.database import get_table, refresh_snapshot
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
_counter_battles = [0]
def _session_battles():
_counter_battles[0] += 1
name = f"war{int(time.time() * 1000)}{_counter_battles[0]}"
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
"birth_date": "1990-01-01",
"accept_terms": "1",
},
allow_redirects=True,
)
return s, name
def _create_war_post(session, title, faction_a="Tabs", faction_b="Spaces"):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"content": "Opinion War host post content for tests.",
"title": title,
"topic": "question",
"war_faction_a": faction_a,
"war_faction_b": faction_b,
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
refresh_snapshot()
post = get_table("posts").find_one(slug=slug)
war = get_table("opinion_wars").find_one(post_uid=post["uid"])
return post, war
def _war_row(war_uid):
refresh_snapshot()
return get_table("opinion_wars").find_one(uid=war_uid)
def _fighter_row(war_uid, user_uid):
refresh_snapshot()
return get_table("opinion_war_fighters").find_one(
war_uid=war_uid, user_uid=user_uid
)
def _user_row(username):
refresh_snapshot()
return get_table("users").find_one(username=username)
def _join(session, war_uid, faction):
return session.post(
f"{BASE_URL}/battles/{war_uid}/join",
data={"faction": faction},
headers=JSON,
)
def _fight(session, war_uid):
return session.post(f"{BASE_URL}/battles/{war_uid}/fight", headers=JSON)
+165
View File
@@ -0,0 +1,165 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timedelta, timezone
import requests
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.opinionwar import rules
from tests.api.battles._helpers import (
JSON,
_create_war_post,
_fight,
_fighter_row,
_join,
_session_battles,
_user_row,
_war_row,
)
from tests.conftest import BASE_URL
def _warp_cooldown(war_uid, user_uid):
fighter = _fighter_row(war_uid, user_uid)
warped = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat()
get_table("opinion_war_fighters").update(
{"uid": fighter["uid"], "last_fight_at": warped}, ["uid"]
)
def test_join_and_switch(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"join-{int(time.time() * 1000)}")
r = _join(s, war["uid"], "a")
assert r.status_code == 200
payload = r.json()
assert payload["ok"] is True
assert payload["data"]["war"]["viewer"]["faction"] == "a"
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
assert fighter["faction"] == "a"
r = _join(s, war["uid"], "b")
assert r.json()["data"]["war"]["viewer"]["faction"] == "b"
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
assert fighter["faction"] == "b"
def test_join_invalid_faction_422(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"badfaction-{int(time.time() * 1000)}")
r = s.post(
f"{BASE_URL}/battles/{war['uid']}/join",
data={"faction": "c"},
headers=JSON,
)
assert r.status_code in (400, 422)
def test_join_requires_login(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"anon-{int(time.time() * 1000)}")
r = requests.post(
f"{BASE_URL}/battles/{war['uid']}/join",
data={"faction": "a"},
allow_redirects=False,
)
assert r.status_code in (303, 401)
def test_fight_spends_coins_and_deals_level_damage(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"fight-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
r = _fight(s, war["uid"])
assert r.status_code == 200
payload = r.json()
user = _user_row(name)
expected = rules.damage_for(user.get("level"))
assert payload["data"]["damage"] == expected
assert payload["data"]["war"]["hp_a"] == expected
war_row = _war_row(war["uid"])
assert int(war_row["hp_a"]) == expected
refresh_snapshot()
farm = get_table("game_farms").find_one(user_uid=user["uid"])
from devplacepy.services.game import economy
assert int(farm["coins"]) == economy.STARTING_COINS - rules.FIGHT_COST_COINS
def test_fight_cooldown_refused(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"cooldown-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
assert _fight(s, war["uid"]).status_code == 200
r = _fight(s, war["uid"])
assert r.status_code == 400
assert "24 hours" in r.json()["error"]["message"]
def test_fight_after_warped_cooldown_lands(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"warp-{int(time.time() * 1000)}")
_join(s, war["uid"], "b")
assert _fight(s, war["uid"]).status_code == 200
_warp_cooldown(war["uid"], _user_row(name)["uid"])
r = _fight(s, war["uid"])
assert r.status_code == 200
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
assert int(fighter["fight_count"]) == 2
def test_fight_without_coins_refused_and_cooldown_kept(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"broke-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
assert _fight(s, war["uid"]).status_code == 200
user = _user_row(name)
_warp_cooldown(war["uid"], user["uid"])
refresh_snapshot()
farm = get_table("game_farms").find_one(user_uid=user["uid"])
get_table("game_farms").update({"uid": farm["uid"], "coins": 0}, ["uid"])
r = _fight(s, war["uid"])
assert r.status_code == 400
assert "coins" in r.json()["error"]["message"]
fighter = _fighter_row(war["uid"], user["uid"])
assert int(fighter["fight_count"]) == 1
r = _fight(s, war["uid"])
assert r.status_code == 400
def test_fight_before_join_refused(app_server):
s, _ = _session_battles()
other, _ = _session_battles()
_, war = _create_war_post(s, f"nojoin-{int(time.time() * 1000)}")
r = _fight(other, war["uid"])
assert r.status_code == 400
assert "Join" in r.json()["error"]["message"]
def test_switch_keeps_dealt_damage(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"defect-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
assert _fight(s, war["uid"]).status_code == 200
r = _join(s, war["uid"], "b")
war_state = r.json()["data"]["war"]
user = _user_row(name)
expected = rules.damage_for(user.get("level"))
assert war_state["hp_a"] == expected
assert war_state["hp_b"] == 0
fighter = _fighter_row(war["uid"], user["uid"])
assert fighter["faction"] == "b"
assert int(fighter["hp_a"]) == expected
def test_actions_on_resolved_war_refused(app_server):
s, _ = _session_battles()
other, _ = _session_battles()
_, war = _create_war_post(s, f"over-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
get_table("opinion_wars").update({"uid": war["uid"], "ends_at": past}, ["uid"])
r = _fight(s, war["uid"])
assert r.status_code == 400
r = _join(other, war["uid"], "b")
assert r.status_code == 400
+43
View File
@@ -0,0 +1,43 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.api.battles._helpers import _create_war_post, _fight, _join, _session_battles
from tests.conftest import BASE_URL
def test_events_ordered_and_incremental(app_server):
s, _ = _session_battles()
other, _ = _session_battles()
_, war = _create_war_post(s, f"events-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
_join(other, war["uid"], "b")
_fight(s, war["uid"])
r = requests.get(f"{BASE_URL}/battles/{war['uid']}/events")
assert r.status_code == 200
payload = r.json()
assert payload["status"] == "active"
seqs = [event["seq"] for event in payload["events"]]
assert seqs == sorted(seqs)
kinds = [event["kind"] for event in payload["events"]]
assert kinds[:3] == ["join", "join", "fight"]
assert "lead_change" in kinds
fight_event = next(e for e in payload["events"] if e["kind"] == "fight")
assert fight_event["faction"] == "a"
assert fight_event["hp_a"] > 0
after = seqs[1]
r = requests.get(
f"{BASE_URL}/battles/{war['uid']}/events", params={"after": after}
)
replay = [event["seq"] for event in r.json()["events"]]
assert replay == [seq for seq in seqs if seq > after]
def test_events_unknown_battle_404(app_server):
r = requests.get(f"{BASE_URL}/battles/nope/events")
assert r.status_code == 404
+73
View File
@@ -0,0 +1,73 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.api.battles._helpers import JSON, _create_war_post, _session_battles
from tests.conftest import BASE_URL
def test_listing_renders_battle_card(app_server):
s, _ = _session_battles()
_create_war_post(s, f"listing-{int(time.time() * 1000)}", "Vim", "Emacs")
r = s.get(f"{BASE_URL}/battles")
assert r.status_code == 200
assert "dp-opinion-war" in r.text
assert "Vim" in r.text
def test_listing_json_shape(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"json-{int(time.time() * 1000)}")
r = s.get(f"{BASE_URL}/battles", headers=JSON)
assert r.status_code == 200
data = r.json()
assert data["current_filter"] == "active"
assert set(data["counts"]) == {"active", "ended", "mine"}
uids = [battle["uid"] for battle in data["battles"]]
assert war["uid"] in uids
battle = next(b for b in data["battles"] if b["uid"] == war["uid"])
assert battle["faction_a"] == "Tabs"
assert battle["pct_a"] + battle["pct_b"] == 100
assert battle["fight_cost"] > 0
def test_listing_guest_access(app_server):
s, _ = _session_battles()
_create_war_post(s, f"guest-{int(time.time() * 1000)}")
r = requests.get(f"{BASE_URL}/battles")
assert r.status_code == 200
assert "dp-opinion-war" in r.text
def test_listing_search_matches_faction(app_server):
s, _ = _session_battles()
marker = f"Zx{int(time.time() * 1000) % 100000}"
_, war = _create_war_post(s, f"search-{marker}", marker, "Others")
r = s.get(f"{BASE_URL}/battles", params={"search": marker}, headers=JSON)
uids = [battle["uid"] for battle in r.json()["battles"]]
assert uids == [war["uid"]]
def test_listing_invalid_filter_falls_back(app_server):
s, _ = _session_battles()
r = s.get(f"{BASE_URL}/battles", params={"filter": "bogus"}, headers=JSON)
assert r.json()["current_filter"] == "active"
def test_battle_state_json(app_server):
s, _ = _session_battles()
_, war = _create_war_post(s, f"state-{int(time.time() * 1000)}")
r = requests.get(f"{BASE_URL}/battles/{war['uid']}")
assert r.status_code == 200
data = r.json()
assert data["uid"] == war["uid"]
assert data["status"] == "active"
assert data["viewer"] is None
assert data["ends_in"]
def test_battle_state_unknown_uid_404(app_server):
r = requests.get(f"{BASE_URL}/battles/does-not-exist", headers=JSON)
assert r.status_code == 404
+94
View File
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timedelta, timezone
import requests
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils.rewards import XP_BATTLE_PART, XP_BATTLE_TOP, XP_BATTLE_WIN
from tests.api.battles._helpers import (
_create_war_post,
_fight,
_join,
_session_battles,
_user_row,
_war_row,
)
from tests.conftest import BASE_URL
def _warp_ended(war_uid):
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
get_table("opinion_wars").update({"uid": war_uid, "ends_at": past}, ["uid"])
def test_resolution_awards_xp_exactly_once(app_server):
s, winner_name = _session_battles()
other, loser_name = _session_battles()
_, war = _create_war_post(s, f"resolve-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
_join(other, war["uid"], "b")
assert _fight(s, war["uid"]).status_code == 200
winner_before = int(_user_row(winner_name)["xp"] or 0)
loser_before = int(_user_row(loser_name)["xp"] or 0)
_warp_ended(war["uid"])
r = requests.get(f"{BASE_URL}/battles/{war['uid']}")
assert r.status_code == 200
state = r.json()
assert state["status"] == "resolved"
assert state["winner"] == "a"
assert state["winner_label"] == "Tabs"
row = _war_row(war["uid"])
assert row["status"] == "resolved"
assert row["resolved_at"]
winner_after = int(_user_row(winner_name)["xp"] or 0)
loser_after = int(_user_row(loser_name)["xp"] or 0)
assert winner_after == winner_before + XP_BATTLE_PART + XP_BATTLE_WIN + XP_BATTLE_TOP
assert loser_after == loser_before
events = requests.get(f"{BASE_URL}/battles/{war['uid']}/events").json()["events"]
results = [event for event in events if event["kind"] == "result"]
assert len(results) == 1
refresh_snapshot()
notes = list(
get_table("notifications").find(
type="battle", user_uid=_user_row(loser_name)["uid"]
)
)
assert any("Battle over" in (n.get("message") or "") for n in notes)
r = requests.get(f"{BASE_URL}/battles/{war['uid']}")
assert r.json()["status"] == "resolved"
assert int(_user_row(winner_name)["xp"] or 0) == winner_after
events = requests.get(f"{BASE_URL}/battles/{war['uid']}/events").json()["events"]
assert len([e for e in events if e["kind"] == "result"]) == 1
def test_draw_awards_participation_only(app_server):
s, name = _session_battles()
_, war = _create_war_post(s, f"draw-{int(time.time() * 1000)}")
_join(s, war["uid"], "a")
before = int(_user_row(name)["xp"] or 0)
_warp_ended(war["uid"])
state = requests.get(f"{BASE_URL}/battles/{war['uid']}").json()
assert state["winner"] == "draw"
assert int(_user_row(name)["xp"] or 0) == before
def test_post_page_resolves_due_war(app_server):
s, _ = _session_battles()
post, war = _create_war_post(s, f"lazy-{int(time.time() * 1000)}")
_join(s, war["uid"], "b")
assert _fight(s, war["uid"]).status_code == 200
_warp_ended(war["uid"])
slug = post.get("slug") or post["uid"]
r = s.get(f"{BASE_URL}/posts/{slug}")
assert r.status_code == 200
assert "wins the war" in r.text
assert _war_row(war["uid"])["status"] == "resolved"
assert _war_row(war["uid"])["winner"] == "b"