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"
+106
View File
@@ -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
View File
+157
View File
@@ -0,0 +1,157 @@
# retoor <retoor@molodetz.nl>
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()
+94
View File
@@ -0,0 +1,94 @@
# retoor <retoor@molodetz.nl>
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) == ""