# Opinion Wars (`devplacepy/services/opinionwar/`, `devplacepy/routers/battles.py`)

This file documents the Opinion War subsystem. Claude Code auto-loads it when a file under
`devplacepy/services/opinionwar/` is read or edited.

## Overview

An Opinion War is a week-long two-faction battle attached to a post, structurally a sibling of
polls: one `opinion_wars` row keyed by `post_uid`, created inside `POST /posts/create` when both
`war_faction_a` and `war_faction_b` are submitted (the composer's "Start Opinion War" builder,
same disabled-inputs opt-in as the poll builder, driven by `static/js/WarComposer.js`). Malformed
faction input (blank, equal casefolded, over 30 chars, or a second war on the same post) silently
creates no war - the `create_poll` precedent. Wars are create-time only: there is deliberately NO
war builder in the post edit modal, because `ends_at` anchors to `created_at + 7 days` and a war
attached later would either be born expired or break that anchor.

Members join faction `a` or `b`, may switch any time (damage already dealt STAYS with the faction
it was dealt to - fighters carry per-side `hp_a`/`hp_b` columns so the sum invariant survives
defection), and fight once per 24h per war. A fight costs `FIGHT_COST_COINS` (25) Code Farm coins
and deals `damage_for(level)` = `100 + 10 * min(level, 20)` HP - deterministic by design (no RNG:
purchasable randomness is an app-store conditional the platform must never trigger). After 7 days
the faction with more HP wins; equal totals are a draw.

## Package layout

```
services/opinionwar/
  rules.py    pure constants and formulas: damage_for, ends_at_for, is_ended, cooldown math,
              leader_of/winner_of, pct_split, ends_in_label. No DB, no network.
  store.py    all DB access: create/join/fight/resolve, the atomic transitions, event
              allocation, batch serialization, the /battles listing queries.
  service.py  OpinionWarService (BaseService): pub/sub relay + resolution/cooldown backstop.
```

`store.py` is the only place that touches the tables. `rules.py` never imports `store`. The
router (`routers/battles.py`) never issues SQL.

## Tables

`opinion_wars` (uid, post_uid UNIQUE, user_uid, faction_a/b, hp_a/b, leader, status
active|resolved, winner a|b|draw, created_at, ends_at, resolved_at), `opinion_war_fighters`
(war_uid+user_uid UNIQUE, faction, hp_a/b = damage dealt FOR each side, fight_count,
last_fight_at, cooldown_notified_at), `opinion_war_events` (war_uid+seq UNIQUE, kind, message,
payload JSON, actor_uid). All three are in `SOFT_DELETE_TABLES` with born-live inserts; columns +
indexes ensured in `init_db()`. Moderation classification: `battle` -> `opinion_wars` in
`REPORTABLE_TARGETS` (creator-authored faction names), fighters/events in `UNREPORTABLE_TABLES`.
`resolve_object_url("battle", uid)` resolves to the parent post URL. Post deletion cascades
soft+hard through `database/ranking.py` under the shared stamp.

## Atomicity: every transition is a conditional UPDATE

All mutations go through `database/atomic.py::conditional_update_row` (which writes `updated_at`
in every statement - the wars and fighters tables carry the column for this reason; the events
table is insert-only and does not).

| Transition | Precondition | rowcount 0 means |
|---|---|---|
| Claim the cooldown | `COALESCE(last_fight_at,'')='' OR last_fight_at <= :cutoff` | fought within 24h - refuse, money untouched |
| Spend the coins | `COALESCE(coins,0) >= :cost` (`conditional_update_farm`) | broke - restore the cooldown, refuse |
| Land the damage | `status='active' AND ends_at > :now` | war ended mid-flight - refund coins, restore cooldown, resolve |
| Switch faction | `faction != :faction` | already on that side - no event |
| Claim a lead flip | `COALESCE(leader,'') != :leader AND status='active'` | someone else owns the flip - no duplicate event/notifications |
| Resolve | `status='active'` (winner computed IN the statement via CASE) | already resolved - no second XP, no second result event |
| Cooldown notification | `cooldown_notified_at != last_fight_at AND last_fight_at = :last` | already notified for this fight - never repeats, re-arms on the next fight |

**The fight sequence is cooldown-first with compensation, a deliberate deviation from the game's
charge-then-claim:** a crash mid-sequence must cost the user one turn, never coins. Claim the
cooldown, then spend, then deal damage; each later failure compensates the earlier steps
(cooldown restore is itself a CAS guarded on the exact value just written, so a concurrent
legitimate fight is never clobbered). Accepted residual: a process crash between the cooldown
stamp and the damage write loses one turn and self-heals in 24h.

**Event seq allocation is one atomic statement** (`allocate_event`): a raw
`INSERT ... SELECT COALESCE(MAX(seq),0)+1 ... WHERE war_uid = :w` inside `with db:` (raw writes
do not auto-commit). The MAX deliberately scans ALL rows including soft-deleted so seq stays
monotonic across a trash restore. This differs from isslop, whose seq counter lives in the single
worker process - war events originate in request handlers on any worker.

## Resolution is lazy, exactly-once, no cron

`resolve_if_due(war)` runs at every read and mutation entry (the batch card loader, the listing,
`GET /battles/{uid}`, events, join, fight). The CAS winner (rowcount 1) alone awards XP via
`award_rewards` (participation `XP_BATTLE_PART` for every fighter with `fight_count > 0`, winners
add `XP_BATTLE_WIN` + `track_action("battle_win")`, the single top damage dealer war-wide adds
`XP_BATTLE_TOP`; a draw pays participation only), emits the `result` event, defers the result
notifications to ALL joined fighters (zero-fight members get the notification, no XP), and
records `battle.resolve` via `audit.record_system`. Race-proven: 8 concurrent resolvers award
exactly once.

## Live events: relay on the lock owner, DB trail is the source of truth

Request handlers NEVER call `services.pubsub.publish` - it is in-process and WS subscribers
converge on the service-lock-owner worker, so a handler-side publish on the other worker would
silently drop the frame. `OpinionWarService` (2s tick, registered in `main.py` beside
`NotificationRelayService`) relays: watermark on `opinion_war_events.id`, publish each new row to
`public.battle.{war_uid}`. The same tick runs a 60s-throttled sweep resolving ended wars nobody
viewed and sending the fight-ready notifications. Clients (`dp-opinion-war`,
`static/js/components/AppOpinionWar.js`) pair the subscription with a 15s `Poller` on
`GET /battles/{uid}/events?after={lastSeq}` and dedupe by seq, so guests, reconnects and
disabled-services test runs all replay from the durable trail.

## Notifications

Type `battle` in `database.NOTIFICATION_TYPES`. Three messages: lead change (to that war's
fighters, actor excluded, actor as `related_uid` so block/mute applies), the result (to all
fighters, recipient-self as `related_uid`), and "Your Fight is ready" (the service sweep, marker
CAS above). All `target_url`s are the post URL, so `view_post`'s existing
`mark_notifications_read_by_target` clears them on view with zero new code.

## The card and the listing

`templates/_opinion_war.html` (local `_war`) renders wherever `_post_card.html`/`post.html`
render, attached by `store.get_wars_by_post_uids` at the four poll-map call sites (feed, profile
posts tab, project devlog, `content.load_detail`). The battlefield strip is the self-contained
`_opinion_war_field.html` + the `.war-field*` layer in `static/css/opinionwar.css`: CSS pixel-art
sprites (castles, soldiers, campfire) drawn with the box-shadow pixel technique on a `--px` unit,
animated with `steps()` keyframes, fully disabled under `prefers-reduced-motion`. The stylesheet
is global in `base.html` (the `engagement.css` precedent - the card renders across many pages)
and opens with the file-scoped `--war-*` palette block (the `isslop.css` pattern). Faction names
are user content: always rendered through `render_title`.

`GET /battles` is the listing (quizzes-pattern: page pagination, active/ended/mine filters,
`_sidebar_search.html`); there is NO battle detail HTML page - `GET /battles/{uid}` is JSON-only
and the post page is the surface. `serialize_war` is the single card/JSON shape (`WarOut`).

## Verification

The feature is a spendable resource + bounded state machine + multi-path read-then-write, so the
root CLAUDE.md four-layer procedure applies to any change here: property checks on `rules.py`
across the full domain, stateful fuzz asserting `war.hp_x == SUM(fighters.hp_x)` and
`coins >= 0` after every action, and REAL multi-process races (concurrent fight = exactly one
lands and exactly one fee; two wars vs 1.5x the fee = exactly one lands; concurrent resolve =
XP once; concurrent join = one fighter row). Persisted tests: `tests/unit/services/opinionwar/`,
`tests/api/battles/`, `tests/api/posts/create.py` (war creation), `tests/e2e/battles/`.
