Opinion Wars: week-long two-faction battles on posts #173

Merged
retoor merged 2 commits from blindxfish/devplacepy:OpinionWar into master 2026-08-22 07:42:15 +02:00
Contributor

A new post attachment type beside polls, in the spirit of the old eRepublik battles: the composer gains a Start Opinion War button (next to Add poll, same disabled-inputs opt-in mechanism) that names exactly two factions, and the battle runs for exactly 7 days from post creation.

Mechanics

  • Members join faction a or b and may defect at any time - damage already dealt stays with the faction it was dealt to (fighters carry per-side hp columns, so the sum invariant survives switching).
  • Fight once per 24h per battle. A fight spends 25 Code Farm coins and deals deterministic level-weighted damage: 100 + 10 * min(level, 20) HP (110 for a newcomer, capped at 300). No randomness anywhere - deliberate, so the purchasable-randomness app-store conditional stays untriggered.
  • After 7 days the bigger total wins; equal totals are a draw. XP via award_rewards: participation for every fighter with at least one fight, a winner bonus, a top-damage-dealer bonus; draws pay participation only. Four badges (Instigator, First Blood, War Veteran, Champion) and a new battle notification type (lead changes, the result, fight-ready).

Architecture

  • Structurally a sibling of polls: one opinion_wars row keyed by post_uid, created inside POST /posts/create (malformed input silently creates no war, the create_poll precedent), batch-attached at the four poll-map call sites, rendered by a partial on the post card. Wars are create-time only - no builder in the edit modal, since ends_at anchors to creation.
  • Every mutation is a conditional UPDATE through conditional_update_row. The fight sequence is cooldown-first with compensation (claim cooldown -> spend coins -> land damage; each later refusal compensates the earlier steps), so a crash can cost a turn but never coins.
  • Resolution is lazy on read, no cron: an exactly-once CAS computes the winner inside the statement, then awards XP, emits the result event and notifies fighters. OpinionWarService (BaseService, lock owner) is the backstop for unviewed wars and sends the fight-ready notifications via a marker CAS that can never repeat.
  • Live updates: durable opinion_war_events trail (per-war atomic seq allocation in one INSERT..SELECT) relayed to pub/sub public.battle.{uid} on the lock owner (handlers never publish - multi-worker frame loss), client pairs the subscription with an incremental ?after= poller and seq dedupe.
  • UI: dp-opinion-war web component adopts server-rendered markup (real forms, full no-JS path); the battlefield is CSS pixel-art (box-shadow sprites: castles, flags, marching soldiers, campfire; steps() animation, off under reduced motion) in opinionwar.css with a file-scoped --war-* palette. GET /battles is a quizzes-style listing (active/ended/mine, search, pagination) linked from the topnav; there is no battle detail page - the post is the surface.

Fan-out

HTML+JSON faces (WarOut/BattlesOut), Devii actions (list_battles/get_battle/battle_events public, join_battle/fight_battle confirm-gated), API docs group Opinion Wars + war params on posts-create, docs prose page opinion-wars, sitemap entry, REPORTABLE_TARGETS["battle"] + cascades + soft-delete registration, audit keys battle.*, README and nested services/opinionwar/CLAUDE.md.

Verification

The four-layer procedure ran against a scratch DB: property checks over the full damage domain, a 1200-step stateful fuzz (war hp always equals the fighter sum, coins never negative, resolved totals frozen), and real 8-process races - a concurrent fight lands exactly once with exactly one fee, 37 coins across two wars allows exactly one fight, concurrent resolution awards XP exactly once, concurrent joins create one fighter row. Persisted tests: tests/unit/services/opinionwar/, tests/api/battles/ (listing, actions, events, resolution), war-creation cases in tests/api/posts/create.py, and tests/e2e/battles/ (composer toggle + poll exclusivity, join/fight flow with live HP updates, switch confirm, guest state). Full three-tier suite run in the project Docker image before pushing.

🤖 Generated with Claude Code

A new post attachment type beside polls, in the spirit of the old eRepublik battles: the composer gains a **Start Opinion War** button (next to *Add poll*, same disabled-inputs opt-in mechanism) that names exactly two factions, and the battle runs for exactly **7 days** from post creation. ## Mechanics - Members join faction a or b and may **defect at any time** - damage already dealt stays with the faction it was dealt to (fighters carry per-side hp columns, so the sum invariant survives switching). - **Fight once per 24h per battle.** A fight spends 25 Code Farm coins and deals deterministic level-weighted damage: `100 + 10 * min(level, 20)` HP (110 for a newcomer, capped at 300). No randomness anywhere - deliberate, so the purchasable-randomness app-store conditional stays untriggered. - After 7 days the bigger total wins; equal totals are a draw. XP via `award_rewards`: participation for every fighter with at least one fight, a winner bonus, a top-damage-dealer bonus; draws pay participation only. Four badges (Instigator, First Blood, War Veteran, Champion) and a new `battle` notification type (lead changes, the result, fight-ready). ## Architecture - **Structurally a sibling of polls**: one `opinion_wars` row keyed by `post_uid`, created inside `POST /posts/create` (malformed input silently creates no war, the `create_poll` precedent), batch-attached at the four poll-map call sites, rendered by a partial on the post card. Wars are create-time only - no builder in the edit modal, since `ends_at` anchors to creation. - **Every mutation is a conditional UPDATE** through `conditional_update_row`. The fight sequence is cooldown-first with compensation (claim cooldown -> spend coins -> land damage; each later refusal compensates the earlier steps), so a crash can cost a turn but never coins. - **Resolution is lazy on read, no cron**: an exactly-once CAS computes the winner inside the statement, then awards XP, emits the `result` event and notifies fighters. `OpinionWarService` (BaseService, lock owner) is the backstop for unviewed wars and sends the fight-ready notifications via a marker CAS that can never repeat. - **Live updates**: durable `opinion_war_events` trail (per-war atomic seq allocation in one INSERT..SELECT) relayed to pub/sub `public.battle.{uid}` on the lock owner (handlers never publish - multi-worker frame loss), client pairs the subscription with an incremental `?after=` poller and seq dedupe. - **UI**: `dp-opinion-war` web component adopts server-rendered markup (real forms, full no-JS path); the battlefield is CSS pixel-art (box-shadow sprites: castles, flags, marching soldiers, campfire; `steps()` animation, off under reduced motion) in `opinionwar.css` with a file-scoped `--war-*` palette. `GET /battles` is a quizzes-style listing (active/ended/mine, search, pagination) linked from the topnav; there is no battle detail page - the post is the surface. ## Fan-out HTML+JSON faces (`WarOut`/`BattlesOut`), Devii actions (`list_battles`/`get_battle`/`battle_events` public, `join_battle`/`fight_battle` confirm-gated), API docs group **Opinion Wars** + war params on `posts-create`, docs prose page `opinion-wars`, sitemap entry, `REPORTABLE_TARGETS["battle"]` + cascades + soft-delete registration, audit keys `battle.*`, README and nested `services/opinionwar/CLAUDE.md`. ## Verification The four-layer procedure ran against a scratch DB: property checks over the full damage domain, a 1200-step stateful fuzz (war hp always equals the fighter sum, coins never negative, resolved totals frozen), and real 8-process races - a concurrent fight lands exactly once with exactly one fee, 37 coins across two wars allows exactly one fight, concurrent resolution awards XP exactly once, concurrent joins create one fighter row. Persisted tests: `tests/unit/services/opinionwar/`, `tests/api/battles/` (listing, actions, events, resolution), war-creation cases in `tests/api/posts/create.py`, and `tests/e2e/battles/` (composer toggle + poll exclusivity, join/fight flow with live HP updates, switch confirm, guest state). Full three-tier suite run in the project Docker image before pushing. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
blindxfish added 2 commits 2026-08-21 00:05:14 +02:00
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>
Fix battles join docs param location and the fight-flow ticker assertion
Some checks failed
DevPlace CI / test (pull_request) Has been cancelled
50baf9d6f1
The auth matrix probes documented endpoints with their documented form
params; the faction param was declared with location body instead of
form, so the anonymous probe sent an empty body and hit 422
validation before the auth guard. And the first fight always triggers
a lead-change event that outranks the fight event at the top of the
ticker, so the e2e flow now asserts on the ticker as a whole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
retoor merged commit 055c7bcd07 into master 2026-08-22 07:42:15 +02:00
Sign in to join this conversation.
No reviewers
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: retoor/devplacepy#173
No description provided.