Compare commits

...

5 Commits

Author SHA1 Message Date
055c7bcd07 Merge pull request 'Opinion Wars: week-long two-faction battles on posts' (#173) from blindxfish/devplacepy:OpinionWar into master
Some checks failed
DevPlace CI / test (push) Failing after 1h23m35s
Reviewed-on: retoor/devplacepy#173
2026-08-22 07:42:14 +02:00
50baf9d6f1 Fix battles join docs param location and the fight-flow ticker assertion
Some checks failed
DevPlace CI / test (pull_request) Has been cancelled
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>
2026-08-21 00:05:00 +02:00
80956ce0f4 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>
2026-08-20 23:30:02 +02:00
2f26dbb1e7 Update
Some checks failed
DevPlace CI / test (push) Failing after 1h42m39s
2026-08-20 00:38:07 +02:00
516219513a Merge pull request 'Show a post's image on its card, and show it full size' (#172) from blindxfish/devplacepy:PostCardPolish into master
Some checks failed
DevPlace CI / test (push) Failing after 1h34m22s
Reviewed-on: retoor/devplacepy#172
2026-08-19 22:11:02 +02:00
72 changed files with 4051 additions and 8 deletions

View File

@ -155,6 +155,7 @@ Nested `CLAUDE.md` files (loaded automatically by Claude Code only when a file i
| `devplacepy/services/pubsub/CLAUDE.md` | Database-free pub/sub bus |
| `devplacepy/push/CLAUDE.md` | Push notification providers: the `PushProvider` protocol, the registry, Web Push and APNs, registration storage |
| `devplacepy/services/game/CLAUDE.md` | Code Farm idle game (economy invariants, raids, the one-pure-function rule) |
| `devplacepy/services/opinionwar/CLAUDE.md` | Opinion Wars (week-long faction battles on posts: atomic fight/resolve transitions, cooldown-before-coins compensation, event seq allocation, relay-on-lock-owner) |
| `devplacepy/services/quiz/CLAUDE.md` | Quizzes (the terminal publish lock, attempt atomicity, answer-key withholding, AI free-text grading, the best-attempt scoreboard) |
| `devplacepy/services/CLAUDE.md` | Background task queue, AI correction/modifier, presence, live view relay, `BaseService`/`ServiceManager` |
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
@ -201,6 +202,7 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
| `/game` | game/ package - see `services/game/CLAUDE.md` |
| `/reports`, `/admin/moderation`, `/workspaces` | reports.py, admin/moderation.py, workspaces.py - see `services/moderation/CLAUDE.md` |
| `/quizzes` | quizzes/ package - see `services/quiz/CLAUDE.md` |
| `/battles` | battles.py - see `services/opinionwar/CLAUDE.md` |
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |

View File

@ -97,6 +97,7 @@ devplacepy/
| `/leaderboard` | Contributor ranking by total stars earned |
| `/game` | **Code Farm** cooperative idle game (member-only): plant projects that build over real time, harvest coins and XP, upgrade CI, buy plots, and water friends' builds at `/game/farm/{username}`. Live over pub/sub; every endpoint negotiates JSON |
| `/quizzes` | **Quizzes**: author quizzes, play them, and climb the cross-quiz scoreboard. Three-column hub with filters (`all`/`todo`/`done`/`mine`/`drafts`), search, per-viewer state badges, and the scoreboard rail; `/quizzes/{slug}` detail, `/quizzes/{slug}/edit` builder, `/quizzes/{slug}/attempts/{uid}` player, `/quizzes/scoreboard` JSON. Publishing is permanent. Every endpoint negotiates JSON |
| `/battles` | **Opinion Wars**: week-long two-faction battles attached to posts, started from the composer's *Start Opinion War* builder. Members join a side and fight once a day (25 Code Farm coins, level-weighted damage); the pixel-art battle card shows live HP bars, a countdown, top contributors and an event ticker. `/battles` lists battles (`active`/`ended`/`mine` + search); `/battles/{uid}` state, `/battles/{uid}/events` replay, `/battles/{uid}/join` and `/battles/{uid}/fight` actions |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/issues` | Issue tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing, an admin planning report over a selectable set of open tickets (each ticket's full text reproduced verbatim so the document hands straight to a coding agent), and file attachments on open issues and comments (mirrored to the Gitea tracker) |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
@ -169,6 +170,25 @@ scoreboard on the right. Guests read published quizzes and see the board; they c
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
`devplace quiz prune`.
## Opinion Wars
**Opinion Wars** (`/battles`) are week-long two-faction battles attached to posts, in the spirit of
old eRepublik battles: settle tabs-versus-spaces by showing up daily and fighting for your side.
- **Start one from the composer.** The *Start Opinion War* button next to *Add poll* names the two
factions; the battle runs for exactly 7 days from the moment the post is published.
- **Join and fight.** Any signed-in member picks a side and may fight once every 24 hours per
battle. A fight costs 25 Code Farm coins and deals deterministic, level-weighted damage
(100 HP + 10 per site level, capped at level 20) - no randomness, dedication wins wars.
- **Defection is allowed.** Switch factions any time; damage already dealt stays where it landed.
- **Live pixel-art card.** The battle renders on the post as a CSS pixel-art battlefield with HP
bars, a countdown, your rank, top contributors and a live event ticker (joins, defections,
fights, lead changes) over pub/sub with an incremental replay fallback.
- **Rewards.** When the week ends the bigger total wins: every fighter earns XP, the winning side
and the top damage dealer earn bonuses, and battle badges (*Instigator*, *First Blood*,
*War Veteran*, *Champion*) mark the milestones. Notifications cover lead changes, the result and
your next fight being ready.
## Code Farm
The **Code Farm** (`/game`) is a cooperative idle game in the spirit of Farmville, themed for developers. Each member owns a farm of plots and plays asynchronously - nothing has to happen in real time.

View File

@ -97,6 +97,8 @@ QUIZ_SCOREBOARD_LIMIT = 20
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
QUIZ_LIST_PER_PAGE = 20
BATTLES_LIST_PER_PAGE = 10
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
DEFAULT_MODIFIER_PROMPT = (
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"

View File

@ -53,6 +53,7 @@ from devplacepy.utils import (
XP_UPVOTE,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.opinionwar import store as war_store
from devplacepy.services.correction import schedule_correction
from devplacepy.services.ai_modifier import schedule_modification
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
@ -627,6 +628,7 @@ def detail_context(
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
"war": detail.get("war"),
"project_link": detail.get("project_link"),
"maturity": detail.get("maturity", "general"),
}
@ -817,6 +819,9 @@ def load_detail(
"reactions": reactions,
"bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
"war": war_store.get_war_serialized_for_post(item["uid"], user)
if target_type == "post"
else None,
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
"maturity": get_maturity(target_type, item["uid"])["level"],
}

View File

@ -89,6 +89,11 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
if not poll:
return "/feed"
return resolve_object_url("post", poll.get("post_uid", ""))
if target_type == "battle":
war = get_table("opinion_wars").find_one(uid=target_uid)
if not war:
return "/battles"
return resolve_object_url("post", war.get("post_uid", ""))
if target_type == "workspace":
instance = get_table("instances").find_one(uid=target_uid)
return f"/admin/containers/{instance['uid']}" if instance else "/admin/containers"

View File

@ -17,6 +17,7 @@ REPORTABLE_TARGETS: dict[str, str] = {
"message": "messages",
"quiz": "quizzes",
"poll": "polls",
"battle": "opinion_wars",
"award": "awards",
"user": "users",
"issue": "issue_tickets",
@ -48,6 +49,8 @@ UNREPORTABLE_TABLES: dict[str, str] = {
"bookmarks": "private to the owner",
"follows": "relationship rows, carry no authored content",
"poll_votes": "private ballots",
"opinion_war_fighters": "membership and damage counters, carry no authored content",
"opinion_war_events": "server-composed battle log rows, not authored content",
"quiz_attempts": "private to the participant",
"quiz_answers": "private to the participant",
"sessions": "authentication state",

View File

@ -19,6 +19,7 @@ NOTIFICATION_TYPES = [
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "quiz_attempt", "label": "Quiz attempts", "description": "Someone completes one of your quizzes"},
{"key": "battle", "label": "Opinion Wars", "description": "Lead changes, results and fight-ready alerts for battles you joined"},
{"key": "workspace", "label": "Dev workspaces", "description": "Idle, quota, retention and moderation notices for your dev workspaces"},
{"key": "moderation", "label": "Moderation", "description": "Acknowledgements for reports you file and decisions taken on your content"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},

View File

@ -150,6 +150,16 @@ def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str)
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"])
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
if target_type == "post" and "opinion_wars" in db.tables:
for uid in uids:
for war in db["opinion_wars"].find(post_uid=uid, deleted_at=None):
soft_delete(
"opinion_war_fighters", deleted_by, stamp=stamp, war_uid=war["uid"]
)
soft_delete(
"opinion_war_events", deleted_by, stamp=stamp, war_uid=war["uid"]
)
soft_delete("opinion_wars", deleted_by, stamp=stamp, post_uid=uid)
def delete_engagement(target_type: str, target_uids: list) -> None:
@ -181,6 +191,14 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
if "poll_options" in tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=uid)
if target_type == "post" and "opinion_wars" in tables:
for uid in uids:
for war in db["opinion_wars"].find(post_uid=uid):
if "opinion_war_fighters" in tables:
db["opinion_war_fighters"].delete(war_uid=war["uid"])
if "opinion_war_events" in tables:
db["opinion_war_events"].delete(war_uid=war["uid"])
db["opinion_wars"].delete(post_uid=uid)
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:

View File

@ -1580,6 +1580,98 @@ def init_db():
)
_index(db, "quiz_answers", "idx_quiz_answers_quiz", ["quiz_uid"])
opinion_wars = get_table("opinion_wars")
for column, example in (
("uid", ""),
("post_uid", ""),
("user_uid", ""),
("faction_a", ""),
("faction_b", ""),
("hp_a", 0),
("hp_b", 0),
("leader", ""),
("status", "active"),
("winner", ""),
("created_at", ""),
("ends_at", ""),
("resolved_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not opinion_wars.has_column(column):
opinion_wars.create_column_by_example(column, example)
_index(db, "opinion_wars", "idx_opinion_wars_post", ["post_uid"], unique=True)
_index(db, "opinion_wars", "idx_opinion_wars_status_ends", ["status", "ends_at"])
_index(
db,
"opinion_wars",
"idx_opinion_wars_live_created",
["created_at"],
where="deleted_at IS NULL",
)
opinion_war_fighters = get_table("opinion_war_fighters")
for column, example in (
("uid", ""),
("war_uid", ""),
("user_uid", ""),
("faction", ""),
("hp_a", 0),
("hp_b", 0),
("fight_count", 0),
("last_fight_at", ""),
("cooldown_notified_at", ""),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not opinion_war_fighters.has_column(column):
opinion_war_fighters.create_column_by_example(column, example)
_index(
db,
"opinion_war_fighters",
"idx_opinion_war_fighters_war_user",
["war_uid", "user_uid"],
unique=True,
)
_index(
db,
"opinion_war_fighters",
"idx_opinion_war_fighters_war_faction",
["war_uid", "faction"],
)
_index(
db,
"opinion_war_fighters",
"idx_opinion_war_fighters_last_fight",
["last_fight_at"],
)
opinion_war_events = get_table("opinion_war_events")
for column, example in (
("uid", ""),
("war_uid", ""),
("seq", 0),
("kind", ""),
("message", ""),
("payload", ""),
("actor_uid", ""),
("created_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not opinion_war_events.has_column(column):
opinion_war_events.create_column_by_example(column, example)
_index(
db,
"opinion_war_events",
"idx_opinion_war_events_war_seq",
["war_uid", "seq"],
unique=True,
)
_index(db, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
_index(
db,

View File

@ -51,6 +51,9 @@ SOFT_DELETE_TABLES = [
"quiz_options",
"quiz_attempts",
"quiz_answers",
"opinion_wars",
"opinion_war_fighters",
"opinion_war_events",
"content_reports",
"moderation_actions",
"content_maturity",

View File

@ -22,6 +22,7 @@ from . import (
admin,
game,
quizzes,
battles,
)
ORDERED_GROUPS = [
@ -46,4 +47,5 @@ ORDERED_GROUPS = [
admin.GROUP,
game.GROUP,
quizzes.GROUP,
battles.GROUP,
]

View File

@ -0,0 +1,149 @@
# retoor <retoor@molodetz.nl>
from devplacepy.services.opinionwar import rules
from .._shared import endpoint, field
FILTER_KEYS = ["active", "ended", "mine"]
SAMPLE_WAR = {
"uid": "0198f2c0-2222-7aaa-8bbb-000000000002",
"post_uid": "0198f2c0-3333-7aaa-8bbb-000000000003",
"post_url": "/posts/8bbb000000000003-tabs-or-spaces",
"post_title": "Tabs or spaces?",
"faction_a": "Tabs",
"faction_b": "Spaces",
"hp_a": 12548,
"hp_b": 7362,
"pct_a": 63,
"pct_b": 37,
"leader": "a",
"fighter_count": 42,
"status": "active",
"winner": "",
"ends_at": "2026-08-27T12:00:00+00:00",
"ends_in": "2d 14h 32m",
"last_seq": 87,
"fight_cost": rules.FIGHT_COST_COINS,
"top_contributors": [
{"username": "code_warrior", "faction": "a", "hp": 982},
],
"recent_events": [
{"seq": 87, "kind": "fight", "message": "code_warrior dealt 300 HP for Tabs", "faction": "a"},
],
"viewer": {
"faction": "a",
"hp": 256,
"rank": 7,
"can_fight": True,
"next_fight_at": "",
},
}
GROUP = {
"slug": "battles",
"title": "Opinion Wars",
"intro": f"""
# Opinion Wars
An Opinion War is a week-long two-faction battle attached to a post. The creator names
exactly two factions when creating the post (the `war_faction_a` / `war_faction_b` fields
on `POST /posts/create`); from that moment the battle runs for exactly
{rules.WAR_DURATION_DAYS} days.
Any signed-in member joins one of the two factions and may **fight** once every
{rules.FIGHT_COOLDOWN_HOURS} hours per battle. A fight costs {rules.FIGHT_COST_COINS}
Code Farm coins and deals deterministic, level-weighted damage for the fighter's faction:
`{rules.BASE_DAMAGE} + {rules.LEVEL_DAMAGE_STEP} * min(level, {rules.LEVEL_DAMAGE_CAP})`
HP, so a level 1 member deals {rules.damage_for(1)} HP and the bonus caps at
{rules.damage_for(rules.LEVEL_DAMAGE_CAP)} HP. There is no randomness. Switching factions
is allowed at any time; damage already dealt stays with the faction it was dealt to.
When the week is over the faction with more HP wins. Resolution is evaluated lazily on
read (no background clock): the first read after the deadline freezes the totals, awards
XP (participation for every fighter with at least one fight, a bonus for the winning
side, a bonus for the single top damage dealer) and notifies every fighter. Equal totals
are a draw with participation XP only.
Every battle keeps an ordered event log (kinds `join`, `switch`, `fight`, `lead_change`,
`result`) replayable with the `after` cursor; live frames are also published on the
pub/sub topic `public.battle.{{uid}}`.
All endpoints negotiate HTML or JSON. POST bodies are form encoded. Action POSTs answer
`{{"ok": true, "redirect": "...", "data": {{...}}}}`; a refused action (cooldown, missing
coins, ended battle) answers `400` as `{{"error": {{"status": 400, "message": "..."}}}}`.
""",
"endpoints": [
endpoint(
id="battles-list",
method="GET",
path="/battles",
title="Battle listing",
summary="Opinion Wars with HP totals, filter counts and the viewer's faction state.",
auth="public",
negotiation=True,
params=[
field("search", "query", "string", False, "tabs", "Match a faction name or the creator's username."),
field("filter", "query", "enum", False, "active", "Which battles to list.", options=FILTER_KEYS),
field("page", "query", "integer", False, "1", "1-based page number."),
],
sample_response={"battles": [SAMPLE_WAR], "counts": {"active": 3, "ended": 12, "mine": 1}},
),
endpoint(
id="battles-get",
method="GET",
path="/battles/{uid}",
title="Battle state",
summary="One battle's full serialized state, resolving it first when its week is over.",
auth="public",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
],
sample_response=SAMPLE_WAR,
),
endpoint(
id="battles-events",
method="GET",
path="/battles/{uid}/events",
title="Battle events",
summary="The ordered battle event log, replayable incrementally with the after cursor.",
auth="public",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
field("after", "query", "integer", False, "0", "Return only events with a seq greater than this."),
field("limit", "query", "integer", False, "500", "Maximum events to return."),
],
sample_response={"events": SAMPLE_WAR["recent_events"], "status": "active"},
),
endpoint(
id="battles-join",
method="POST",
path="/battles/{uid}/join",
title="Join or switch faction",
summary="Join faction a or b, or switch an existing fighter to the other side.",
auth="user",
encoding="form",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
field("faction", "form", "enum", True, "a", "Which side to join or switch to.", options=["a", "b"]),
],
sample_response={"ok": True, "redirect": SAMPLE_WAR["post_url"], "data": {"war": SAMPLE_WAR}},
),
endpoint(
id="battles-fight",
method="POST",
path="/battles/{uid}/fight",
title="Fight",
summary=(
f"Spend {rules.FIGHT_COST_COINS} Code Farm coins and deal level-weighted HP damage "
f"for your faction. Once per {rules.FIGHT_COOLDOWN_HOURS} hours per battle."
),
auth="user",
encoding="form",
params=[
field("uid", "path", "string", True, SAMPLE_WAR["uid"], "Battle uid."),
],
sample_response={"ok": True, "redirect": SAMPLE_WAR["post_url"], "data": {"war": SAMPLE_WAR, "damage": 300}},
),
],
}

View File

@ -117,6 +117,22 @@ four ways to sign requests.
"",
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
),
field(
"war_faction_a",
"form",
"string",
False,
"",
"Optional Opinion War faction A name (max 30 chars). Both faction names start the week-long battle.",
),
field(
"war_faction_b",
"form",
"string",
False,
"",
"Optional Opinion War faction B name (max 30 chars). Must differ from faction A.",
),
],
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
),

View File

@ -49,6 +49,7 @@ from devplacepy.utils import get_current_user, time_ago, safe_next, client_ip
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import (
auth,
battles,
feed,
posts,
comments,
@ -106,6 +107,7 @@ from devplacepy.services.backup import BackupService
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.pubsub import PubSubService
from devplacepy.services.notification_relay import NotificationRelayService
from devplacepy.services.opinionwar.service import OpinionWarService
from devplacepy.services.live_view_relay import LiveViewRelayService
from devplacepy.services.presence_relay import PresenceRelayService
from devplacepy.services import presence
@ -272,6 +274,7 @@ async def lifespan(app: FastAPI):
service_manager.register(DbApiJobService())
service_manager.register(PubSubService())
service_manager.register(NotificationRelayService())
service_manager.register(OpinionWarService())
service_manager.register(LiveViewRelayService())
service_manager.register(PresenceRelayService())
service_manager.register(DeepsearchService())
@ -504,6 +507,7 @@ app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
app.include_router(quizzes.router, prefix="/quizzes")
app.include_router(battles.router, prefix="/battles")
app.include_router(workspaces.router, prefix="/workspaces")

View File

@ -176,6 +176,8 @@ class PostForm(BaseModel):
attachment_uids: list[str] = []
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
war_faction_a: str = Field(default="", max_length=30)
war_faction_b: str = Field(default="", max_length=30)
@field_validator("poll_options")
@classmethod
@ -198,6 +200,17 @@ class PostForm(BaseModel):
return normalize_poll_options(value)
class WarJoinForm(BaseModel):
faction: str
@field_validator("faction")
@classmethod
def valid_faction(cls, value):
if value not in ("a", "b"):
raise ValueError("Faction must be a or b")
return value
class PostEditForm(BaseModel):
content: str = Field(min_length=10, max_length=125000)
title: str = Field(default="", max_length=500)

View File

@ -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) |

View File

@ -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})

View File

@ -189,7 +189,9 @@ async def clippy_proxy(request: Request):
"Content-Type": "application/json",
"X-App-Reference": "devplace-devii-v-1-0-0",
}
if cfg.get("devii_ai_key"):
if user.get("api_key"):
headers["Authorization"] = f"Bearer {user['api_key']}"
elif cfg.get("devii_ai_key"):
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
async with stealth.stealth_async_client(timeout=45.0) as client:
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)

View File

@ -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",

View File

@ -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,

View File

@ -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})

View File

@ -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:

View File

@ -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,

View File

@ -151,6 +151,15 @@ from devplacepy.schemas.dbapi import (
DbTableOut,
NlQueryOut,
)
from devplacepy.schemas.battles import (
BattlesOut,
WarContributorOut,
WarEventOut,
WarEventsOut,
WarOut,
WarPersonOut,
WarViewerOut,
)
from devplacepy.schemas.quiz import (
QuizAnswerOut,
QuizAnswerResultOut,

View File

@ -0,0 +1,82 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from typing import Optional
from devplacepy.schemas.base import _Out
class WarPersonOut(_Out):
uid: str = ""
username: str = ""
avatar_seed: Optional[str] = None
level: int = 1
class WarContributorOut(WarPersonOut):
faction: str = ""
hp: int = 0
class WarEventOut(_Out):
seq: int = 0
kind: str = ""
message: str = ""
faction: str = ""
created_at: str = ""
hp_a: Optional[int] = None
hp_b: Optional[int] = None
damage: Optional[int] = None
winner: Optional[str] = None
class WarViewerOut(_Out):
faction: str = ""
hp: int = 0
rank: int = 0
fight_count: int = 0
last_fight_at: str = ""
next_fight_at: str = ""
can_fight: bool = False
class WarOut(_Out):
uid: str = ""
post_uid: str = ""
post_url: str = ""
post_title: str = ""
author: Optional[WarPersonOut] = None
faction_a: str = ""
faction_b: str = ""
hp_a: int = 0
hp_b: int = 0
pct_a: int = 50
pct_b: int = 50
leader: str = ""
fighter_count: int = 0
status: str = "active"
winner: str = ""
winner_label: str = ""
created_at: str = ""
ends_at: str = ""
ends_in: str = ""
resolved_at: str = ""
last_seq: int = 0
fight_cost: int = 0
top_contributors: list[WarContributorOut] = []
recent_events: list[WarEventOut] = []
viewer: Optional[WarViewerOut] = None
class BattlesOut(_Out):
battles: list[WarOut] = []
current_filter: str = "active"
counts: dict = {}
search: str = ""
pagination: dict = {}
class WarEventsOut(_Out):
events: list[WarEventOut] = []
status: str = ""

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any, Optional
from devplacepy.schemas.base import _Out
from devplacepy.schemas.battles import WarOut
from devplacepy.schemas.content import (
AttachmentOut,
CommentItemOut,
@ -33,6 +34,7 @@ class FeedItemOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
war: Optional[WarOut] = None
project_link: Optional[ProjectLinkOut] = None
@ -134,6 +136,7 @@ class PostDetailOut(_Out):
reactions: ReactionsOut = ReactionsOut()
bookmarked: bool = False
poll: Optional[PollOut] = None
war: Optional[WarOut] = None
comment_count: Optional[int] = None
related_posts: list[FeedItemOut] = []
topics: list[str] = []

View File

@ -426,6 +426,7 @@ def _build_sitemap(base_url):
)
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/quizzes", changefreq="daily", priority="0.8"))
urlset.append(url_element(f"{base_url}/battles", changefreq="daily", priority="0.7"))
urlset.append(
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
)

View File

@ -2,7 +2,7 @@ This file documents the audit log subsystem. Claude Code auto-loads it when a fi
## Audit log (`services/audit/`)
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 288 keys across 42 domains.
**Admin-only, append-only** record of every state-changing action. The authoritative event catalogue is `events.md` (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 293 keys across 43 domains.
### Package layout

View File

@ -24,6 +24,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"reaction": "engagement",
"bookmark": "engagement",
"poll": "engagement",
"battle": "engagement",
"project": "project",
"file": "project_files",
"dir": "project_files",

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from ..spec import Action, Catalog
from .admin import ADMIN_ACTIONS
from .auth import AUTH_ACTIONS
from .battles import BATTLE_ACTIONS
from .comments import COMMENTS_ACTIONS
from .dbapi import DBAPI_ACTIONS
from .engagement import ENGAGEMENT_ACTIONS
@ -48,6 +49,7 @@ ACTIONS: tuple[Action, ...] = (
+ GATEWAY_ACTIONS
+ GAME_ACTIONS
+ QUIZ_ACTIONS
+ BATTLE_ACTIONS
+ MODERATION_ACTIONS
)

View File

@ -0,0 +1,85 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from ..spec import Action
from ._shared import body, confirm, path, query
BATTLE_ACTIONS: tuple[Action, ...] = (
Action(
name="list_battles",
method="GET",
path="/battles",
summary="List Opinion War battles",
description=(
"Returns week-long two-faction battles attached to posts, with HP totals, "
"percentages, top contributors and the viewer's own faction state."
),
handler="http",
requires_auth=False,
read_only=True,
params=(
query("search", "Match a faction name or the creator's username."),
query("filter", "One of active, ended, mine. Defaults to active."),
query("page", "1-based page number."),
),
),
Action(
name="get_battle",
method="GET",
path="/battles/{uid}",
summary="Get one Opinion War battle's full state",
handler="http",
requires_auth=False,
read_only=True,
params=(path("uid", "Battle uid."),),
),
Action(
name="battle_events",
method="GET",
path="/battles/{uid}/events",
summary="Read a battle's event log",
description=(
"Ordered battle events (join, switch, fight, lead_change, result). Pass "
"after to replay incrementally from a sequence number."
),
handler="http",
requires_auth=False,
read_only=True,
params=(
path("uid", "Battle uid."),
query("after", "Return only events with a seq greater than this."),
query("limit", "Maximum events to return."),
),
),
Action(
name="join_battle",
method="POST",
path="/battles/{uid}/join",
summary="Join or switch to a faction in an Opinion War",
description=(
"Joins faction a or b, or switches an existing fighter to the other side. "
"Damage already dealt stays with the faction it was dealt to."
),
handler="http",
requires_auth=True,
params=(
path("uid", "Battle uid."),
body("faction", "Which side to join or switch to: a or b.", required=True),
confirm(),
),
),
Action(
name="fight_battle",
method="POST",
path="/battles/{uid}/fight",
summary="Fight for your faction in an Opinion War",
description=(
"Spends 25 Code Farm coins and deals level-weighted HP damage for the "
"fighter's faction. Allowed once per 24 hours per battle."
),
handler="http",
requires_auth=True,
params=(path("uid", "Battle uid."), confirm()),
),
)

View File

@ -36,6 +36,14 @@ POSTS_ACTIONS: tuple[Action, ...] = (
"poll_options",
"Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
),
body(
"war_faction_a",
"Optional Opinion War faction A name (max 30 chars). Both faction names are required to start the week-long battle.",
),
body(
"war_faction_b",
"Optional Opinion War faction B name (max 30 chars). Must differ from faction A.",
),
),
),
Action(

View File

@ -32,6 +32,8 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {
"join_battle",
"fight_battle",
"delete_my_account",
"decide_report",
"suspend_user",

View File

@ -0,0 +1,134 @@
# 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/`.

View File

@ -0,0 +1,6 @@
# retoor <retoor@molodetz.nl>
from . import rules, store
from .store import WarError
__all__ = ["rules", "store", "WarError"]

View File

@ -0,0 +1,111 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from datetime import datetime, timedelta, timezone
WAR_DURATION_DAYS = 7
FIGHT_COST_COINS = 25
FIGHT_COOLDOWN_HOURS = 24
BASE_DAMAGE = 100
LEVEL_DAMAGE_STEP = 10
LEVEL_DAMAGE_CAP = 20
FACTION_NAME_MAX = 30
TOP_CONTRIBUTORS = 3
EVENT_TICKER_LIMIT = 5
EVENT_LIMIT_DEFAULT = 500
EVENT_KINDS = ("join", "switch", "fight", "lead_change", "result")
def _parse(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _now(now: datetime | None = None) -> datetime:
return now or datetime.now(timezone.utc)
def damage_for(level) -> int:
try:
value = int(level or 1)
except (TypeError, ValueError):
value = 1
return BASE_DAMAGE + LEVEL_DAMAGE_STEP * min(max(value, 1), LEVEL_DAMAGE_CAP)
def ends_at_for(created_at: str) -> str:
start = _parse(created_at) or _now()
return (start + timedelta(days=WAR_DURATION_DAYS)).isoformat()
def is_ended(ends_at: str, now: datetime | None = None) -> bool:
ends = _parse(ends_at)
if not ends:
return False
return _now(now) >= ends
def cooldown_cutoff(now: datetime | None = None) -> str:
return (_now(now) - timedelta(hours=FIGHT_COOLDOWN_HOURS)).isoformat()
def cooldown_ready_at(last_fight_at: str) -> str:
last = _parse(last_fight_at)
if not last:
return ""
return (last + timedelta(hours=FIGHT_COOLDOWN_HOURS)).isoformat()
def can_fight_at(last_fight_at: str, now: datetime | None = None) -> bool:
ready = _parse(cooldown_ready_at(last_fight_at))
if not ready:
return True
return _now(now) >= ready
def leader_of(hp_a: int, hp_b: int) -> str:
if hp_a > hp_b:
return "a"
if hp_b > hp_a:
return "b"
return ""
def winner_of(hp_a: int, hp_b: int) -> str:
return leader_of(hp_a, hp_b) or "draw"
def pct_split(hp_a: int, hp_b: int) -> tuple[int, int]:
left = max(0, int(hp_a or 0))
right = max(0, int(hp_b or 0))
total = left + right
if not total:
return 50, 50
pct_a = round(left * 100 / total)
return pct_a, 100 - pct_a
def ends_in_label(ends_at: str, now: datetime | None = None) -> str:
ends = _parse(ends_at)
if not ends:
return ""
remaining = (ends - _now(now)).total_seconds()
if remaining <= 0:
return ""
minutes = int(remaining // 60)
days, minutes = divmod(minutes, 1440)
hours, minutes = divmod(minutes, 60)
if days:
return f"{days}d {hours}h {minutes}m"
if hours:
return f"{hours}h {minutes}m"
return f"{max(minutes, 1)}m"

View File

@ -0,0 +1,90 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import time
from devplacepy.database import db
from devplacepy.services.base import BaseService
from devplacepy.services.pubsub import publish as pubsub_publish
from . import store
BATCH_LIMIT = 500
SWEEP_SECONDS = 60
def battle_topic(war_uid: str) -> str:
return f"public.battle.{war_uid}"
class OpinionWarService(BaseService):
title = "Opinion Wars"
description = (
"Relays persisted battle events onto the pub/sub bus for live cards, resolves "
"ended wars that nobody viewed, and sends the fight-cooldown-ready "
"notifications. Runs on the service lock owner, where every pub/sub "
"subscriber converges; the event table stays the source of truth."
)
default_enabled = True
def __init__(self):
super().__init__(name="opinionwar", interval_seconds=2)
self._watermark = 0
self._primed = False
self._last_sweep = 0.0
def _max_id(self) -> int:
if "opinion_war_events" not in db.tables:
return 0
rows = list(db.query("SELECT MAX(id) AS max_id FROM opinion_war_events"))
value = rows[0]["max_id"] if rows else None
return int(value or 0)
async def _relay_events(self) -> None:
if "opinion_war_events" not in db.tables:
return
if not self._primed:
self._watermark = self._max_id()
self._primed = True
return
rows = list(
db.query(
"SELECT * FROM opinion_war_events WHERE id > :wm "
"ORDER BY id ASC LIMIT :lim",
wm=self._watermark,
lim=BATCH_LIMIT,
)
)
if not rows:
return
delivered = 0
for row in rows:
delivered += await pubsub_publish(
battle_topic(row["war_uid"]), store._event_dict(dict(row))
)
self._watermark = max(row["id"] for row in rows)
if delivered:
self.log(f"relayed {len(rows)} battle event(s), {delivered} live frame(s)")
def _sweep(self) -> None:
now = time.monotonic()
if now - self._last_sweep < SWEEP_SECONDS:
return
self._last_sweep = now
resolved = store.resolve_due_wars()
if resolved:
self.log(f"resolved {resolved} ended war(s)")
notified = 0
for fighter in store.cooldown_ready_fighters():
if store.notify_cooldown_ready(fighter):
notified += 1
if notified:
self.log(f"sent {notified} fight-ready notification(s)")
async def run_once(self) -> None:
await self._relay_events()
self._sweep()
def collect_metrics(self) -> dict:
return {"watermark": self._watermark, "primed": self._primed}

View File

@ -0,0 +1,794 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from devplacepy import config
from devplacepy.database import (
_in_clause,
build_pagination,
conditional_update_row,
db,
get_blocked_uids,
get_table,
get_users_by_uids,
resolve_object_url,
text_search_clause,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.background import background
from devplacepy.services.game.store import (
conditional_update_farm,
ensure_farm,
refund_farm,
)
from devplacepy.utils import create_notification, generate_uid, track_action
from devplacepy.utils.rewards import (
XP_BATTLE_PART,
XP_BATTLE_TOP,
XP_BATTLE_WIN,
award_rewards,
)
from . import rules
FILTERS = ("active", "ended", "mine")
class WarError(Exception):
pass
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime | None = None) -> str:
return (value or _now()).isoformat()
def _wars():
return get_table("opinion_wars")
def _fighters():
return get_table("opinion_war_fighters")
def _events():
return get_table("opinion_war_events")
def born_live(fields: dict) -> dict:
stamp = _iso()
return {
**fields,
"created_at": fields.get("created_at") or stamp,
"updated_at": stamp,
"deleted_at": None,
"deleted_by": None,
}
def faction_label(war: dict, faction: str) -> str:
return war.get("faction_a") if faction == "a" else war.get("faction_b")
def get_war(war_uid: str) -> dict | None:
if not war_uid:
return None
return _wars().find_one(uid=war_uid, deleted_at=None)
def get_war_for_post(post_uid: str) -> dict | None:
if not post_uid:
return None
return _wars().find_one(post_uid=post_uid, deleted_at=None)
def create_war(
post_uid: str, user: dict, faction_a: str, faction_b: str, request=None
) -> str | None:
faction_a = (faction_a or "").strip()
faction_b = (faction_b or "").strip()
if not faction_a or not faction_b:
return None
if len(faction_a) > rules.FACTION_NAME_MAX or len(faction_b) > rules.FACTION_NAME_MAX:
return None
if faction_a.casefold() == faction_b.casefold():
return None
if get_war_for_post(post_uid):
return None
uid = generate_uid()
created_at = _iso()
_wars().insert(
born_live(
{
"uid": uid,
"post_uid": post_uid,
"user_uid": user["uid"],
"faction_a": faction_a,
"faction_b": faction_b,
"hp_a": 0,
"hp_b": 0,
"leader": "",
"status": "active",
"winner": "",
"created_at": created_at,
"ends_at": rules.ends_at_for(created_at),
"resolved_at": "",
}
)
)
label = f"{faction_a} vs {faction_b}"
audit.record(
request,
"battle.create",
user=user,
target_type="battle",
target_uid=uid,
target_label=label,
metadata={"faction_a": faction_a, "faction_b": faction_b},
summary=f"{user['username']} started Opinion War {label}",
links=[audit.target("battle", uid, label), audit.parent("post", post_uid)],
)
track_action(user["uid"], "battle_create")
return uid
def join_war(war: dict, user: dict, faction: str, request=None) -> dict:
war = resolve_if_due(war)
if not war or war.get("status") != "active":
raise WarError("This battle has ended.")
if faction not in ("a", "b"):
raise WarError("Pick faction a or b.")
label = faction_label(war, faction)
fighter = _fighters().find_one(war_uid=war["uid"], user_uid=user["uid"])
if fighter and fighter.get("deleted_at"):
_fighters().update(
{
"id": fighter["id"],
"faction": faction,
"deleted_at": None,
"deleted_by": None,
"updated_at": _iso(),
},
["id"],
)
kind = "join"
elif fighter:
rows = conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
"faction = :faction",
"faction != :faction AND deleted_at IS NULL",
{"faction": faction},
)
if not rows:
return war
kind = "switch"
else:
try:
_fighters().insert(
born_live(
{
"uid": generate_uid(),
"war_uid": war["uid"],
"user_uid": user["uid"],
"faction": faction,
"hp_a": 0,
"hp_b": 0,
"fight_count": 0,
"last_fight_at": "",
"cooldown_notified_at": "",
}
)
)
except IntegrityError:
return join_war(get_war(war["uid"]) or war, user, faction, request)
kind = "join"
verb = "defected to" if kind == "switch" else "joined"
allocate_event(
war["uid"],
kind,
f"{user['username']} {verb} {label}",
{"faction": faction},
user["uid"],
)
audit.record(
request,
f"battle.{kind}",
user=user,
target_type="battle",
target_uid=war["uid"],
target_label=label,
metadata={"faction": faction},
summary=f"{user['username']} {verb} {label}",
links=[audit.target("battle", war["uid"], label), audit.parent("post", war["post_uid"])],
)
return get_war(war["uid"]) or war
def fight(war: dict, user: dict, request=None) -> tuple[dict, int]:
war = resolve_if_due(war)
if not war or war.get("status") != "active":
raise WarError("This battle has ended.")
fighter = _fighters().find_one(
war_uid=war["uid"], user_uid=user["uid"], deleted_at=None
)
if not fighter:
raise WarError("Join a faction first.")
now = _iso()
previous = fighter.get("last_fight_at") or ""
claimed = conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
"last_fight_at = :now",
"(COALESCE(last_fight_at, '') = '' OR last_fight_at <= :cutoff) AND deleted_at IS NULL",
{"now": now, "cutoff": rules.cooldown_cutoff()},
)
if not claimed:
raise WarError("You can fight once every 24 hours.")
farm = ensure_farm(user["uid"])
spent = conditional_update_farm(
farm["uid"],
"coins = COALESCE(coins, 0) - :cost",
"COALESCE(coins, 0) >= :cost",
{"cost": rules.FIGHT_COST_COINS},
)
if not spent:
_restore_cooldown(fighter["uid"], previous, now)
raise WarError(
f"A fight costs {rules.FIGHT_COST_COINS} Code Farm coins - earn some on your farm first."
)
faction = "a" if fighter.get("faction") != "b" else "b"
side = "hp_a" if faction == "a" else "hp_b"
damage = rules.damage_for(user.get("level"))
landed = conditional_update_row(
"opinion_wars",
war["uid"],
f"{side} = COALESCE({side}, 0) + :damage",
"status = 'active' AND ends_at > :now AND deleted_at IS NULL",
{"damage": damage, "now": now},
)
if not landed:
refund_farm(farm["uid"], rules.FIGHT_COST_COINS)
_restore_cooldown(fighter["uid"], previous, now)
resolve_if_due(get_war(war["uid"]))
raise WarError("The battle ended before your attack landed.")
conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
f"{side} = COALESCE({side}, 0) + :damage, fight_count = COALESCE(fight_count, 0) + 1",
"1 = 1",
{"damage": damage},
)
label = faction_label(war, faction)
fresh = get_war(war["uid"]) or war
allocate_event(
war["uid"],
"fight",
f"{user['username']} dealt {damage} HP for {label}",
{
"faction": faction,
"damage": damage,
"hp_a": int(fresh.get("hp_a") or 0),
"hp_b": int(fresh.get("hp_b") or 0),
},
user["uid"],
)
_claim_lead(fresh, user)
track_action(user["uid"], "battle_fight")
audit.record(
request,
"battle.fight",
user=user,
target_type="battle",
target_uid=war["uid"],
target_label=label,
metadata={"faction": faction, "damage": damage, "cost": rules.FIGHT_COST_COINS},
summary=f"{user['username']} dealt {damage} HP for {label}",
links=[audit.target("battle", war["uid"], label), audit.parent("post", war["post_uid"])],
)
return get_war(war["uid"]) or fresh, damage
def _restore_cooldown(fighter_uid: str, previous: str, claimed: str) -> None:
conditional_update_row(
"opinion_war_fighters",
fighter_uid,
"last_fight_at = :previous",
"last_fight_at = :claimed",
{"previous": previous, "claimed": claimed},
)
def _claim_lead(war: dict, user: dict) -> None:
new_leader = rules.leader_of(int(war.get("hp_a") or 0), int(war.get("hp_b") or 0))
if not new_leader:
return
rows = conditional_update_row(
"opinion_wars",
war["uid"],
"leader = :leader",
"COALESCE(leader, '') != :leader AND status = 'active'",
{"leader": new_leader},
)
if not rows:
return
label = faction_label(war, new_leader)
allocate_event(
war["uid"],
"lead_change",
f"{label} took the lead!",
{
"faction": new_leader,
"hp_a": int(war.get("hp_a") or 0),
"hp_b": int(war.get("hp_b") or 0),
},
user["uid"],
)
background.submit(
_notify_fighters,
war["uid"],
f"{label} took the lead in an Opinion War you fight in",
user["uid"],
user["uid"],
)
def _notify_fighters(
war_uid: str, message: str, related_uid: str | None, exclude_uid: str | None
) -> None:
war = get_war(war_uid)
if not war:
return
url = resolve_object_url("post", war["post_uid"])
for row in _fighters().find(war_uid=war_uid, deleted_at=None):
recipient = row.get("user_uid") or ""
if not recipient or recipient == exclude_uid:
continue
create_notification(
recipient, "battle", message, related_uid or recipient, target_url=url
)
def allocate_event(
war_uid: str, kind: str, message: str, payload: dict | None = None, actor_uid: str = ""
) -> dict:
uid = generate_uid()
created_at = _iso()
with db:
db.query(
"INSERT INTO opinion_war_events "
"(uid, war_uid, seq, kind, message, payload, actor_uid, created_at, deleted_at, deleted_by) "
"SELECT :uid, :war_uid, COALESCE(MAX(seq), 0) + 1, :kind, :message, :payload, "
":actor_uid, :created_at, NULL, NULL "
"FROM opinion_war_events WHERE war_uid = :war_uid",
uid=uid,
war_uid=war_uid,
kind=kind,
message=message,
payload=json.dumps(payload or {}, ensure_ascii=False),
actor_uid=actor_uid or "",
created_at=created_at,
)
row = _events().find_one(uid=uid)
return _event_dict(row) if row else {
"seq": 0,
"kind": kind,
"message": message,
"faction": (payload or {}).get("faction", ""),
"created_at": created_at,
}
def _event_dict(row: dict) -> dict:
try:
payload = json.loads(row.get("payload") or "{}")
except (TypeError, ValueError):
payload = {}
if not isinstance(payload, dict):
payload = {}
event = {
"seq": int(row.get("seq") or 0),
"kind": row.get("kind") or "",
"message": row.get("message") or "",
"faction": payload.get("faction", ""),
"created_at": row.get("created_at") or "",
}
for key in ("hp_a", "hp_b", "damage", "winner"):
if key in payload:
event[key] = payload[key]
return event
def events_for(war_uid: str, after_seq: int = 0, limit: int = rules.EVENT_LIMIT_DEFAULT) -> list[dict]:
capped = max(1, min(int(limit or 1), rules.EVENT_LIMIT_DEFAULT))
rows = _events().find(
war_uid=war_uid,
seq={">": max(0, int(after_seq or 0))},
deleted_at=None,
order_by=["seq"],
_limit=capped,
)
return [_event_dict(dict(row)) for row in rows]
def resolve_if_due(war: dict | None) -> dict | None:
if not war or war.get("status") != "active":
return war
if not rules.is_ended(war.get("ends_at") or ""):
return war
return resolve_war(war)
def resolve_war(war: dict) -> dict:
rows = conditional_update_row(
"opinion_wars",
war["uid"],
"status = 'resolved', resolved_at = :now, "
"winner = CASE WHEN COALESCE(hp_a, 0) > COALESCE(hp_b, 0) THEN 'a' "
"WHEN COALESCE(hp_b, 0) > COALESCE(hp_a, 0) THEN 'b' ELSE 'draw' END",
"status = 'active' AND deleted_at IS NULL",
{"now": _iso()},
)
resolved = get_war(war["uid"]) or war
if rows:
_award_and_announce(resolved)
return resolved
def _award_and_announce(war: dict) -> None:
winner = war.get("winner") or "draw"
hp_a = int(war.get("hp_a") or 0)
hp_b = int(war.get("hp_b") or 0)
fighters = list(_fighters().find(war_uid=war["uid"], deleted_at=None))
active = [row for row in fighters if int(row.get("fight_count") or 0) > 0]
for row in active:
award_rewards(row["user_uid"], XP_BATTLE_PART)
if winner in ("a", "b") and row.get("faction") == winner:
award_rewards(row["user_uid"], XP_BATTLE_WIN)
track_action(row["user_uid"], "battle_win")
if winner in ("a", "b") and active:
top = min(
active,
key=lambda row: (
-(int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0)),
int(row.get("id") or 0),
),
)
award_rewards(top["user_uid"], XP_BATTLE_TOP)
if winner == "a":
message = f"Battle over: {war['faction_a']} wins {hp_a} to {hp_b}"
elif winner == "b":
message = f"Battle over: {war['faction_b']} wins {hp_b} to {hp_a}"
else:
message = f"Battle over: a draw at {hp_a} HP each"
allocate_event(
war["uid"], "result", message, {"winner": winner, "hp_a": hp_a, "hp_b": hp_b}, ""
)
background.submit(_notify_fighters, war["uid"], message, None, None)
audit.record_system(
"battle.resolve",
target_type="battle",
target_uid=war["uid"],
target_label=f"{war['faction_a']} vs {war['faction_b']}",
metadata={"winner": winner, "hp_a": hp_a, "hp_b": hp_b},
summary=message,
links=[
audit.target("battle", war["uid"], f"{war['faction_a']} vs {war['faction_b']}"),
audit.parent("post", war["post_uid"]),
],
)
def resolve_due_wars(limit: int = 25) -> int:
if "opinion_wars" not in db.tables:
return 0
due = list(
_wars().find(
status="active",
ends_at={"<=": _iso()},
deleted_at=None,
order_by=["ends_at"],
_limit=max(1, limit),
)
)
resolved = 0
for war in due:
if resolve_war(dict(war)).get("status") == "resolved":
resolved += 1
return resolved
def cooldown_ready_fighters(limit: int = 50) -> list[dict]:
if "opinion_war_fighters" not in db.tables:
return []
rows = db.query(
"SELECT f.* FROM opinion_war_fighters f "
"JOIN opinion_wars w ON w.uid = f.war_uid "
"WHERE f.deleted_at IS NULL AND w.deleted_at IS NULL AND w.status = 'active' "
"AND COALESCE(f.last_fight_at, '') != '' AND f.last_fight_at <= :cutoff "
"AND COALESCE(f.cooldown_notified_at, '') != f.last_fight_at "
"LIMIT :limit",
cutoff=rules.cooldown_cutoff(),
limit=max(1, limit),
)
return [dict(row) for row in rows]
def notify_cooldown_ready(fighter: dict) -> bool:
last = fighter.get("last_fight_at") or ""
rows = conditional_update_row(
"opinion_war_fighters",
fighter["uid"],
"cooldown_notified_at = :last",
"COALESCE(cooldown_notified_at, '') != :last AND last_fight_at = :last",
{"last": last},
)
if not rows:
return False
war = get_war(fighter.get("war_uid") or "")
if not war:
return True
create_notification(
fighter["user_uid"],
"battle",
"Your Fight is ready - return to the battle",
fighter["user_uid"],
target_url=resolve_object_url("post", war["post_uid"]),
)
return True
def get_wars_by_post_uids(post_uids: list, user: dict | None = None) -> dict:
uids = [uid for uid in (post_uids or []) if uid]
if not uids or "opinion_wars" not in db.tables:
return {}
placeholders, params = _in_clause(uids)
wars = [
dict(row)
for row in db.query(
f"SELECT * FROM opinion_wars WHERE post_uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
)
]
if not wars:
return {}
serialized = serialize_wars(wars, user)
return {item["post_uid"]: item for item in serialized}
def get_war_serialized(war: dict | None, user: dict | None = None) -> dict | None:
if not war:
return None
items = serialize_wars([dict(war)], user)
return items[0] if items else None
def get_war_serialized_for_post(post_uid: str, user: dict | None = None) -> dict | None:
return get_war_serialized(get_war_for_post(post_uid), user)
def serialize_wars(wars: list[dict], user: dict | None = None) -> list[dict]:
wars = [resolve_if_due(war) for war in wars]
war_uids = [war["uid"] for war in wars]
placeholders, params = _in_clause(war_uids)
fighters_by_war: dict[str, list[dict]] = {uid: [] for uid in war_uids}
if "opinion_war_fighters" in db.tables:
for row in db.query(
f"SELECT * FROM opinion_war_fighters WHERE war_uid IN ({placeholders}) "
"AND deleted_at IS NULL",
**params,
):
fighters_by_war.setdefault(row["war_uid"], []).append(dict(row))
events_by_war: dict[str, list[dict]] = {uid: [] for uid in war_uids}
if "opinion_war_events" in db.tables:
for row in db.query(
"SELECT * FROM ("
"SELECT e.*, ROW_NUMBER() OVER (PARTITION BY war_uid ORDER BY seq DESC) AS rn "
f"FROM opinion_war_events e WHERE war_uid IN ({placeholders}) "
"AND deleted_at IS NULL) WHERE rn <= :ticker",
**params,
ticker=rules.EVENT_TICKER_LIMIT,
):
events_by_war.setdefault(row["war_uid"], []).append(dict(row))
posts_by_uid: dict[str, dict] = {}
post_placeholders, post_params = _in_clause([war["post_uid"] for war in wars])
if "posts" in db.tables:
for row in db.query(
f"SELECT uid, slug, title FROM posts WHERE uid IN ({post_placeholders}) "
"AND deleted_at IS NULL",
**post_params,
):
posts_by_uid[row["uid"]] = dict(row)
user_uids = {war["user_uid"] for war in wars}
for rows in fighters_by_war.values():
ranked = _ranked(rows)
for fighter in ranked[: rules.TOP_CONTRIBUTORS]:
user_uids.add(fighter["user_uid"])
users = get_users_by_uids(list(user_uids))
return [
_serialize_war(
war,
_ranked(fighters_by_war.get(war["uid"], [])),
events_by_war.get(war["uid"], []),
posts_by_uid.get(war["post_uid"]),
users,
user,
)
for war in wars
]
def _ranked(fighters: list[dict]) -> list[dict]:
return sorted(
fighters,
key=lambda row: (
-(int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0)),
int(row.get("id") or 0),
),
)
def _person(users: dict, uid: str) -> dict:
row = users.get(uid) or {}
return {
"uid": uid,
"username": row.get("username") or "unknown",
"avatar_seed": row.get("avatar_seed"),
"level": int(row.get("level") or 1),
}
def _serialize_war(
war: dict,
ranked: list[dict],
event_rows: list[dict],
post: dict | None,
users: dict,
viewer: dict | None,
) -> dict:
hp_a = int(war.get("hp_a") or 0)
hp_b = int(war.get("hp_b") or 0)
pct_a, pct_b = rules.pct_split(hp_a, hp_b)
events = sorted((_event_dict(row) for row in event_rows), key=lambda e: -e["seq"])
last_seq = events[0]["seq"] if events else 0
status = war.get("status") or "active"
winner = war.get("winner") or ""
viewer_row = None
viewer_uid = (viewer or {}).get("uid") or ""
if viewer_uid:
for index, row in enumerate(ranked):
if row.get("user_uid") == viewer_uid:
viewer_row = {
"faction": row.get("faction") or "",
"hp": int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0),
"rank": index + 1,
"fight_count": int(row.get("fight_count") or 0),
"last_fight_at": row.get("last_fight_at") or "",
"next_fight_at": rules.cooldown_ready_at(row.get("last_fight_at") or ""),
"can_fight": status == "active"
and rules.can_fight_at(row.get("last_fight_at") or ""),
}
break
top_contributors = [
{
**_person(users, row["user_uid"]),
"faction": row.get("faction") or "",
"hp": int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0),
}
for row in ranked[: rules.TOP_CONTRIBUTORS]
if int(row.get("hp_a") or 0) + int(row.get("hp_b") or 0) > 0
]
slug = (post or {}).get("slug") or war["post_uid"]
return {
"uid": war["uid"],
"post_uid": war["post_uid"],
"post_url": f"/posts/{slug}",
"post_title": (post or {}).get("title") or "",
"author": _person(users, war["user_uid"]),
"faction_a": war.get("faction_a") or "",
"faction_b": war.get("faction_b") or "",
"hp_a": hp_a,
"hp_b": hp_b,
"pct_a": pct_a,
"pct_b": pct_b,
"leader": war.get("leader") or "",
"fighter_count": len(ranked),
"status": status,
"winner": winner,
"winner_label": faction_label(war, winner) or "" if winner in ("a", "b") else "",
"created_at": war.get("created_at") or "",
"ends_at": war.get("ends_at") or "",
"ends_in": rules.ends_in_label(war.get("ends_at") or ""),
"resolved_at": war.get("resolved_at") or "",
"last_seq": last_seq,
"fight_cost": rules.FIGHT_COST_COINS,
"top_contributors": top_contributors,
"recent_events": events,
"viewer": viewer_row,
}
def list_wars(
*,
viewer: dict | None = None,
war_filter: str = "active",
search: str = "",
page: int = 1,
per_page: int = config.BATTLES_LIST_PER_PAGE,
) -> tuple[list[dict], dict]:
table = _wars()
resolve_due_wars()
clauses, filters = _list_clauses(table, viewer, war_filter, search)
if clauses is None:
return [], build_pagination(1, 0, per_page)
total = table.count(*clauses, deleted_at=None, **filters)
pagination = build_pagination(page, total, per_page)
offset = (pagination["page"] - 1) * pagination["per_page"]
rows = list(
table.find(
*clauses,
deleted_at=None,
order_by=["-created_at"],
_limit=pagination["per_page"],
_offset=offset,
**filters,
)
)
return serialize_wars([dict(row) for row in rows], viewer), pagination
def filter_counts(viewer: dict | None, search: str = "") -> dict[str, int]:
table = _wars()
counts: dict[str, int] = {}
for name in FILTERS:
clauses, filters = _list_clauses(table, viewer, name, search)
counts[name] = (
0 if clauses is None else table.count(*clauses, deleted_at=None, **filters)
)
return counts
def _joined_war_uids(viewer_uid: str) -> list[str]:
return [
row["war_uid"]
for row in _fighters().find(user_uid=viewer_uid, deleted_at=None)
]
def _list_clauses(table, viewer, war_filter, search):
if not table.exists:
return None, {}
viewer_uid = (viewer or {}).get("uid", "")
columns = table.table.columns
clauses = []
filters: dict = {}
if war_filter == "ended":
filters["status"] = "resolved"
elif war_filter == "mine":
if not viewer_uid:
return None, {}
joined = _joined_war_uids(viewer_uid)
own = columns.user_uid == viewer_uid
clauses.append(or_(own, columns.uid.in_(joined)) if joined else own)
else:
filters["status"] = "active"
if viewer_uid and war_filter != "mine":
blocked = get_blocked_uids(viewer_uid)
if blocked:
clauses.append(columns.user_uid.notin_(blocked))
match = text_search_clause(
table, search, ("faction_a", "faction_b"), author_field="user_uid"
)
if match is not None:
clauses.append(match)
return clauses, filters

View File

@ -0,0 +1,714 @@
:root {
--war-a-rgb: 66, 133, 244;
--war-a: rgb(var(--war-a-rgb));
--war-b-rgb: 234, 67, 53;
--war-b: rgb(var(--war-b-rgb));
--war-stone: #9b93ad;
--war-stone-dark: #5b5372;
--war-window: #ffd66b;
--war-door: #241b31;
--war-skin: #e8b98c;
--war-steel: #cfd6e4;
--war-flame: #ffb02e;
--war-flame-hot: #ff5a1f;
--war-wood: #7a4a2b;
--war-ground-top: #35502f;
--war-ground-deep: #22371f;
--war-sky-top: #120c26;
--war-sky-deep: #322357;
--war-leg: #2c2440;
--war-gold: #f4c542;
}
dp-opinion-war {
display: block;
}
.war-card {
position: relative;
z-index: 1;
margin: var(--space-md) 0;
padding: var(--space-md);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
}
.war-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.war-head-title {
font-family: var(--font-mono);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent-light);
}
.war-countdown {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.war-bars {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
.war-side {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.war-side-b {
text-align: right;
}
.war-side-name {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.war-side-a .war-side-name {
color: var(--war-a);
}
.war-side-b .war-side-name {
color: var(--war-b);
}
.war-vs {
font-family: var(--font-mono);
font-weight: 700;
font-size: 1.1rem;
color: var(--war-gold);
}
.war-bar {
position: relative;
height: 20px;
border: 1px solid var(--border-light);
border-radius: 4px;
background: var(--bg-input);
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.war-bar-fill {
position: absolute;
inset: 0;
width: var(--war-pct, 0%);
transition: width 0.4s ease;
}
.war-bar-a .war-bar-fill {
background: rgba(var(--war-a-rgb), 0.55);
}
.war-bar-b .war-bar-fill {
left: auto;
right: 0;
background: rgba(var(--war-b-rgb), 0.55);
}
.war-bar-pct {
position: absolute;
top: 50%;
transform: translateY(-50%);
color: var(--text-primary);
z-index: 1;
}
.war-bar-a .war-bar-pct {
left: var(--space-sm);
}
.war-bar-b .war-bar-pct {
right: var(--space-sm);
}
.war-side-hp {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.war-field {
--px: 3px;
position: relative;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-sm);
height: 96px;
padding: 0 var(--space-md) 12px;
border-radius: var(--radius);
background: linear-gradient(var(--war-sky-top), var(--war-sky-deep));
overflow: hidden;
margin-bottom: var(--space-md);
}
.war-ground {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 10px;
background: linear-gradient(var(--war-ground-top), var(--war-ground-deep));
}
.war-px {
position: absolute;
top: 0;
left: 0;
width: var(--px);
height: var(--px);
}
.war-castle {
position: relative;
z-index: 1;
width: calc(var(--px) * 5);
height: calc(var(--px) * 8);
flex-shrink: 0;
}
.war-castle-b {
transform: scaleX(-1);
}
.war-castle-px {
background: var(--war-stone-dark);
box-shadow:
calc(var(--px) * 2) 0 0 0 var(--war-stone-dark),
calc(var(--px) * 4) 0 0 0 var(--war-stone-dark),
0 var(--px) 0 0 var(--war-stone-dark),
var(--px) var(--px) 0 0 var(--war-stone-dark),
calc(var(--px) * 2) var(--px) 0 0 var(--war-stone-dark),
calc(var(--px) * 3) var(--px) 0 0 var(--war-stone-dark),
calc(var(--px) * 4) var(--px) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 2) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 2) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-stone),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 2) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 3) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 3) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--war-window),
calc(var(--px) * 3) calc(var(--px) * 3) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 3) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 4) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 4) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 4) 0 0 var(--war-stone),
calc(var(--px) * 3) calc(var(--px) * 4) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 4) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 5) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 5) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 5) 0 0 var(--war-stone),
calc(var(--px) * 3) calc(var(--px) * 5) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 5) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 6) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 6) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 6) 0 0 var(--war-door),
calc(var(--px) * 3) calc(var(--px) * 6) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 6) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 7) 0 0 var(--war-stone-dark),
var(--px) calc(var(--px) * 7) 0 0 var(--war-stone),
calc(var(--px) * 2) calc(var(--px) * 7) 0 0 var(--war-door),
calc(var(--px) * 3) calc(var(--px) * 7) 0 0 var(--war-stone),
calc(var(--px) * 4) calc(var(--px) * 7) 0 0 var(--war-stone-dark);
}
.war-flag {
top: calc(var(--px) * -4);
left: calc(var(--px) * 2);
background: var(--war-stone-dark);
box-shadow:
0 var(--px) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 2) 0 0 var(--war-stone-dark),
0 calc(var(--px) * 3) 0 0 var(--war-stone-dark),
var(--px) 0 0 0 var(--war-flag-color),
calc(var(--px) * 2) 0 0 0 var(--war-flag-color),
var(--px) var(--px) 0 0 var(--war-flag-color);
}
.war-flag-a {
--war-flag-color: var(--war-a);
}
.war-flag-b {
--war-flag-color: var(--war-b);
}
.war-troops {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-sm);
flex-shrink: 1;
min-width: 0;
transition: transform 0.6s ease;
}
.war-field-lead-a .war-troops-a {
transform: translateX(calc(var(--px) * 2));
}
.war-field-lead-b .war-troops-b {
transform: translateX(calc(var(--px) * -2));
}
.war-soldier {
position: relative;
display: block;
width: calc(var(--px) * 6);
height: calc(var(--px) * 7);
animation: war-march 1.2s steps(1, end) infinite;
}
.war-soldier:nth-child(2) {
animation-delay: 0.4s;
}
.war-soldier:nth-child(3) {
animation-delay: 0.8s;
}
.war-soldier:nth-child(4) {
animation-delay: 0.2s;
}
.war-soldier-a {
--war-soldier-main: var(--war-a);
}
.war-soldier-b {
--war-soldier-main: var(--war-b);
transform: scaleX(-1);
}
.war-soldier-px {
background: transparent;
box-shadow:
var(--px) 0 0 0 var(--war-soldier-main),
calc(var(--px) * 2) 0 0 0 var(--war-soldier-main),
calc(var(--px) * 3) 0 0 0 var(--war-soldier-main),
var(--px) var(--px) 0 0 var(--war-skin),
calc(var(--px) * 2) var(--px) 0 0 var(--war-skin),
calc(var(--px) * 3) var(--px) 0 0 var(--war-skin),
calc(var(--px) * 5) var(--px) 0 0 var(--war-steel),
0 calc(var(--px) * 2) 0 0 var(--war-soldier-main),
var(--px) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 4) calc(var(--px) * 2) 0 0 var(--war-soldier-main),
calc(var(--px) * 5) calc(var(--px) * 2) 0 0 var(--war-steel),
var(--px) calc(var(--px) * 3) 0 0 var(--war-soldier-main),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--war-soldier-main),
calc(var(--px) * 3) calc(var(--px) * 3) 0 0 var(--war-soldier-main),
calc(var(--px) * 4) calc(var(--px) * 3) 0 0 var(--war-skin),
calc(var(--px) * 5) calc(var(--px) * 3) 0 0 var(--war-steel),
var(--px) calc(var(--px) * 4) 0 0 var(--war-soldier-main),
calc(var(--px) * 2) calc(var(--px) * 4) 0 0 var(--war-soldier-main),
calc(var(--px) * 3) calc(var(--px) * 4) 0 0 var(--war-soldier-main),
var(--px) calc(var(--px) * 5) 0 0 var(--war-leg),
calc(var(--px) * 3) calc(var(--px) * 5) 0 0 var(--war-leg),
var(--px) calc(var(--px) * 6) 0 0 var(--war-leg),
calc(var(--px) * 3) calc(var(--px) * 6) 0 0 var(--war-leg);
}
.war-campfire {
position: relative;
z-index: 1;
width: calc(var(--px) * 5);
height: calc(var(--px) * 5);
flex-shrink: 0;
}
.war-fire-logs {
background: transparent;
box-shadow:
var(--px) calc(var(--px) * 3) 0 0 var(--war-wood),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--war-wood),
calc(var(--px) * 3) calc(var(--px) * 3) 0 0 var(--war-wood),
0 calc(var(--px) * 4) 0 0 var(--war-wood),
var(--px) calc(var(--px) * 4) 0 0 var(--war-wood),
calc(var(--px) * 2) calc(var(--px) * 4) 0 0 var(--war-wood),
calc(var(--px) * 3) calc(var(--px) * 4) 0 0 var(--war-wood),
calc(var(--px) * 4) calc(var(--px) * 4) 0 0 var(--war-wood);
}
.war-fire-flame {
background: transparent;
animation: war-flicker 0.8s steps(1, end) infinite;
box-shadow:
calc(var(--px) * 2) 0 0 0 var(--war-flame-hot),
var(--px) var(--px) 0 0 var(--war-flame),
calc(var(--px) * 3) var(--px) 0 0 var(--war-flame),
var(--px) calc(var(--px) * 2) 0 0 var(--war-flame),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-flame-hot),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-flame);
}
.war-victory {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
margin-bottom: var(--space-md);
border: 1px solid var(--war-gold);
border-radius: var(--radius);
background: rgba(var(--accent-rgb), 0.12);
}
.war-victory-title {
font-family: var(--font-mono);
font-weight: 700;
color: var(--war-gold);
}
.war-mine {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
}
.war-mine-faction {
font-weight: 700;
}
.war-tag-a {
color: var(--war-a);
}
.war-tag-b {
color: var(--war-b);
}
.war-mine-stats {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.war-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.war-join-btn.war-join-a {
border-color: rgba(var(--war-a-rgb), 0.6);
}
.war-join-btn.war-join-b {
border-color: rgba(var(--war-b-rgb), 0.6);
}
.war-fight-btn[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.war-block-label {
display: block;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-muted);
margin-bottom: var(--space-xs);
}
.war-contributors {
margin-bottom: var(--space-md);
}
.war-contributor-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.war-contributor {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
}
.war-contributor-hp {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.8rem;
}
.war-text-a {
color: var(--war-a);
}
.war-text-b {
color: var(--war-b);
}
.war-events {
margin-bottom: var(--space-md);
}
.war-ticker {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.war-event {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
font-size: 0.85rem;
min-width: 0;
}
.war-event-empty {
color: var(--text-muted);
}
.war-event-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
background: var(--text-muted);
}
.war-dot-a {
background: var(--war-a);
}
.war-dot-b {
background: var(--war-b);
}
.war-event-message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.war-event-time {
margin-left: auto;
flex-shrink: 0;
font-size: 0.72rem;
color: var(--text-muted);
}
.war-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.war-all-link {
font-weight: 600;
color: var(--accent-light);
}
.war-foot-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.8rem;
}
.war-foot-btn:hover {
color: var(--text-primary);
background: var(--bg-card-hover);
}
@keyframes war-march {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(calc(var(--px) * -1));
}
}
@keyframes war-flicker {
0%,
100% {
box-shadow:
calc(var(--px) * 2) 0 0 0 var(--war-flame-hot),
var(--px) var(--px) 0 0 var(--war-flame),
calc(var(--px) * 3) var(--px) 0 0 var(--war-flame),
var(--px) calc(var(--px) * 2) 0 0 var(--war-flame),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-flame-hot),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-flame);
}
50% {
box-shadow:
var(--px) 0 0 0 var(--war-flame),
calc(var(--px) * 2) var(--px) 0 0 var(--war-flame-hot),
calc(var(--px) * 3) 0 0 0 var(--war-flame),
var(--px) calc(var(--px) * 2) 0 0 var(--war-flame-hot),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--war-flame),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--war-flame);
}
}
.war-soldier-b.war-soldier {
animation-name: war-march-b;
}
@keyframes war-march-b {
0%,
100% {
transform: scaleX(-1) translateY(0);
}
50% {
transform: scaleX(-1) translateY(calc(var(--px) * -1));
}
}
.war-filter-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
}
.war-listing-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.war-listing-meta {
font-size: 0.8rem;
color: var(--text-muted);
}
.war-builder {
margin: var(--space-sm) 0;
padding: var(--space-md);
border: 1px dashed var(--border-light);
border-radius: var(--radius);
}
.war-builder[hidden] {
display: none;
}
.war-builder-hint {
margin: 0 0 var(--space-sm);
font-size: 0.82rem;
color: var(--text-secondary);
}
.war-builder-row {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.war-builder-row input {
flex: 1;
min-width: 0;
}
.war-builder-vs {
font-family: var(--font-mono);
font-weight: 700;
color: var(--war-gold);
}
.war-builder-error {
margin: var(--space-sm) 0 0;
color: var(--danger);
font-size: 0.82rem;
}
@media (max-width: 768px) {
.war-field {
--px: 2px;
height: 76px;
}
}
@media (max-width: 480px) {
.war-bars {
gap: var(--space-sm);
}
.war-vs {
font-size: 0.9rem;
}
.war-bar-pct {
display: none;
}
.war-builder-row {
flex-direction: column;
align-items: stretch;
}
}
@media (prefers-reduced-motion: reduce) {
.war-soldier,
.war-fire-flame {
animation: none;
}
.war-bar-fill,
.war-troops {
transition: none;
}
}

View File

@ -681,6 +681,20 @@
margin-bottom: var(--space-xl);
}
.quiz-slide-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.quiz-slide-counter {
color: var(--text-secondary);
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.quiz-question-answered .quiz-answer-form {
opacity: 0.7;
}

View File

@ -15,6 +15,7 @@ import { CounterManager } from "./CounterManager.js";
import { ReactionBar } from "./ReactionBar.js";
import { BookmarkManager } from "./BookmarkManager.js";
import { PollManager } from "./PollManager.js";
import { WarComposer } from "./WarComposer.js";
import { ApiKeyManager } from "./ApiKeyManager.js";
import { AvatarRegenerator } from "./AvatarRegenerator.js";
import { CustomizationToggle } from "./CustomizationToggle.js";
@ -87,6 +88,7 @@ class Application {
this.reactions = new ReactionBar();
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
this.warComposer = new WarComposer();
this.apiKey = new ApiKeyManager();
this.avatarRegenerator = new AvatarRegenerator();
this.customizationToggle = new CustomizationToggle();

View File

@ -0,0 +1,106 @@
// retoor <retoor@molodetz.nl>
export class WarComposer {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
document.addEventListener("submit", (event) => this.onSubmit(event), true);
}
onClick(event) {
const toggle = event.target.closest("[data-war-toggle]");
if (toggle) {
event.preventDefault();
this.toggleBuilder(toggle);
return;
}
const pollToggle = event.target.closest("[data-poll-toggle]");
if (pollToggle) {
this.closeWhenPollOpens(pollToggle);
}
}
toggleBuilder(toggle) {
const form = toggle.closest("form");
const builder = form ? form.querySelector("[data-war-builder]") : null;
if (!builder) {
return;
}
const activating = builder.hidden;
if (activating) {
this.closePollBuilder(form);
}
builder.hidden = !activating;
this.setWarEnabled(builder, activating);
const label = toggle.querySelector("[data-war-toggle-label]");
if (label) {
label.textContent = activating ? "Remove Opinion War" : "Start Opinion War";
}
}
closePollBuilder(form) {
const pollBuilder = form.querySelector("[data-poll-builder]");
const pollToggle = form.querySelector("[data-poll-toggle]");
if (pollBuilder && pollToggle && !pollBuilder.hidden) {
pollToggle.click();
}
}
closeWhenPollOpens(pollToggle) {
const form = pollToggle.closest("form");
const builder = form ? form.querySelector("[data-war-builder]") : null;
const toggle = form ? form.querySelector("[data-war-toggle]") : null;
if (!builder || !toggle || builder.hidden) {
return;
}
builder.hidden = true;
this.setWarEnabled(builder, false);
const label = toggle.querySelector("[data-war-toggle-label]");
if (label) {
label.textContent = "Start Opinion War";
}
}
setWarEnabled(builder, enabled) {
builder.querySelectorAll("input[name='war_faction_a'], input[name='war_faction_b']").forEach((input) => {
input.disabled = !enabled;
if (!enabled) {
input.value = "";
}
});
const error = builder.querySelector("[data-war-error]");
if (error) {
error.hidden = true;
error.textContent = "";
}
}
onSubmit(event) {
const form = event.target;
if (!form || typeof form.querySelector !== "function") {
return;
}
const builder = form.querySelector("[data-war-builder]");
if (!builder || builder.hidden) {
return;
}
const factionA = form.querySelector("input[name='war_faction_a']");
const factionB = form.querySelector("input[name='war_faction_b']");
const nameA = factionA ? factionA.value.trim() : "";
const nameB = factionB ? factionB.value.trim() : "";
let problem = "";
if (!nameA || !nameB) {
problem = "Name both factions, or remove the Opinion War.";
} else if (nameA.toLowerCase() === nameB.toLowerCase()) {
problem = "The two factions need different names.";
}
if (problem) {
event.preventDefault();
event.stopImmediatePropagation();
const error = builder.querySelector("[data-war-error]");
if (error) {
error.textContent = problem;
error.hidden = false;
}
}
}
}

View File

@ -0,0 +1,254 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { Http } from "../Http.js";
import { Poller } from "../Poller.js";
const POLL_INTERVAL_MS = 15000;
const COUNTDOWN_INTERVAL_MS = 30000;
const TICKER_CAP = 5;
export class AppOpinionWar extends Component {
connectedCallback() {
this._uid = this.attr("uid");
this._topic = this.attr("topic");
this._lastSeq = this.intAttr("seq", 0);
this._endsAt = this.attr("ends-at");
this._status = this.attr("status", "active");
this._countdown = this.querySelector("[data-war-countdown]");
this._ticker = this.querySelector("[data-war-ticker]");
this._victory = this.querySelector("[data-war-victory]");
this._victoryTitle = this.querySelector("[data-war-victory-title]");
this._actions = this.querySelector("[data-war-actions]");
this._field = this.querySelector("[data-war-field]");
this._subscribed = false;
this._onSubmitBound = (event) => this._onSubmit(event);
this.addEventListener("submit", this._onSubmitBound);
if (this._status === "active") {
this._subscribe();
this._poller = new Poller(() => this._poll(), POLL_INTERVAL_MS, { immediate: false, pauseHidden: true });
this._timer = window.setInterval(() => this._tickCountdown(), COUNTDOWN_INTERVAL_MS);
this._tickCountdown();
}
}
disconnectedCallback() {
this._teardown();
this.removeEventListener("submit", this._onSubmitBound);
}
_teardown() {
if (this._poller) {
this._poller.stop();
this._poller = null;
}
if (this._timer) {
window.clearInterval(this._timer);
this._timer = null;
}
if (this._subscribed && window.app && window.app.pubsub) {
window.app.pubsub.unsubscribe(this._topic, this._onFrame);
this._subscribed = false;
}
}
_subscribe() {
const app = window.app;
if (!this._topic || !app || !app.pubsub || typeof app.pubsub.subscribe !== "function") return;
this._onFrame = (frame) => this._apply(frame);
app.pubsub.subscribe(this._topic, this._onFrame);
this._subscribed = true;
}
async _poll() {
if (this._status !== "active") return;
let payload;
try {
payload = await Http.getJson(`/battles/${this._uid}/events?after=${this._lastSeq}`);
} catch (error) {
return;
}
(payload.events || []).forEach((event) => this._apply(event));
if (payload.status === "resolved" && this._status === "active") {
this._refreshState();
}
}
_apply(event) {
if (!event || typeof event.seq !== "number") return;
if (event.seq > 0 && event.seq <= this._lastSeq) return;
if (event.seq > 0) this._lastSeq = event.seq;
if (typeof event.hp_a === "number" && typeof event.hp_b === "number") {
this._renderTotals(event.hp_a, event.hp_b);
}
this._prependEvent(event);
if (event.kind === "result") {
this._refreshState();
}
}
async _refreshState() {
let war;
try {
war = await Http.getJson(`/battles/${this._uid}`);
} catch (error) {
return;
}
this._renderWar(war);
}
async _onSubmit(event) {
const form = event.target.closest("form[data-war-action]");
if (!form || !this.contains(form)) return;
event.preventDefault();
event.stopPropagation();
const action = form.dataset.warAction;
const confirmText = form.dataset.warConfirm;
if (confirmText && window.app && window.app.dialog) {
const accepted = await window.app.dialog.confirm({ message: confirmText });
if (!accepted) return;
}
const button = form.querySelector("button[type=submit]");
if (button) button.disabled = true;
const params = {};
new FormData(form).forEach((value, key) => {
params[key] = value;
});
try {
const result = await Http.send(form.getAttribute("action"), params);
const war = result && result.data ? result.data.war : null;
if (action === "fight" && war) {
this._renderWar(war);
this._toast(`You dealt ${result.data.damage} HP!`);
} else {
window.location.reload();
}
} catch (error) {
this._toast(String(error && error.message ? error.message : error));
if (button) button.disabled = false;
}
}
_toast(message) {
if (window.app && window.app.toast && typeof window.app.toast.show === "function") {
window.app.toast.show(message, { type: "info" });
}
}
_renderTotals(hpA, hpB) {
const total = Math.max(0, hpA) + Math.max(0, hpB);
const pctA = total ? Math.round((Math.max(0, hpA) * 100) / total) : 50;
const pctB = total ? 100 - pctA : 50;
this._setText("[data-war-hp-a]", hpA.toLocaleString());
this._setText("[data-war-hp-b]", hpB.toLocaleString());
this._setText("[data-war-pct-a]", `${pctA}%`);
this._setText("[data-war-pct-b]", `${pctB}%`);
const barA = this.querySelector("[data-war-bar-a]");
const barB = this.querySelector("[data-war-bar-b]");
if (barA) barA.style.setProperty("--war-pct", `${pctA}%`);
if (barB) barB.style.setProperty("--war-pct", `${pctB}%`);
if (this._field) {
this._field.classList.toggle("war-field-lead-a", hpA > hpB);
this._field.classList.toggle("war-field-lead-b", hpB > hpA);
}
}
_setText(selector, value) {
const node = this.querySelector(selector);
if (node) node.textContent = value;
}
_renderTicker(events) {
if (!this._ticker || !events.length) return;
this._ticker.textContent = "";
events.slice(0, TICKER_CAP).forEach((event) => {
this._ticker.appendChild(this._buildEvent(event, ""));
});
}
_buildEvent(event, timeLabel) {
const item = document.createElement("li");
item.className = `war-event war-event-${event.kind}`;
const dot = document.createElement("span");
dot.className = `war-event-dot war-dot-${event.faction || "n"}`;
dot.setAttribute("aria-hidden", "true");
const message = document.createElement("span");
message.className = "war-event-message";
message.textContent = event.message;
item.append(dot, message);
if (timeLabel) {
const time = document.createElement("span");
time.className = "war-event-time";
time.textContent = timeLabel;
item.appendChild(time);
}
return item;
}
_prependEvent(event) {
if (!this._ticker || !event.message) return;
const empty = this._ticker.querySelector(".war-event-empty");
if (empty) empty.remove();
this._ticker.prepend(this._buildEvent(event, "just now"));
while (this._ticker.children.length > TICKER_CAP) {
this._ticker.removeChild(this._ticker.lastChild);
}
}
_renderWar(war) {
if (!war || !war.uid) return;
this._renderTotals(war.hp_a, war.hp_b);
if (typeof war.last_seq === "number" && war.last_seq > this._lastSeq) {
this._lastSeq = war.last_seq;
this._renderTicker(war.recent_events || []);
}
if (war.viewer) {
this._setText("[data-war-mine-hp]", war.viewer.hp.toLocaleString());
const fight = this.querySelector("[data-war-fight]");
if (fight) {
fight.disabled = !war.viewer.can_fight || war.status !== "active";
fight.textContent = war.viewer.can_fight
? `Fight (${war.fight_cost} coins)`
: "On cooldown";
}
}
if (war.status === "resolved" && this._status === "active") {
this._finishResolved(war);
}
if (war.ends_at) this._endsAt = war.ends_at;
}
_finishResolved(war) {
this._status = "resolved";
this._teardown();
if (this._actions) this._actions.hidden = true;
if (this._countdown) this._countdown.textContent = "Battle ended";
if (this._victoryTitle) {
this._victoryTitle.textContent =
war.winner === "draw"
? `It's a draw at ${war.hp_a.toLocaleString()} HP each`
: `${war.winner_label} wins the war!`;
}
if (this._victory) this._victory.hidden = false;
}
_tickCountdown() {
if (!this._countdown || this._status !== "active") return;
const ends = Date.parse(this._endsAt);
if (Number.isNaN(ends)) return;
const remaining = ends - Date.now();
if (remaining <= 0) {
this._countdown.textContent = "Battle ended";
this._refreshState();
return;
}
const minutes = Math.floor(remaining / 60000);
const days = Math.floor(minutes / 1440);
const hours = Math.floor((minutes % 1440) / 60);
const mins = minutes % 60;
const label = days ? `${days}d ${hours}h ${mins}m` : hours ? `${hours}h ${mins}m` : `${Math.max(mins, 1)}m`;
this._countdown.textContent = `Battle ends in ${label}`;
}
}
customElements.define("dp-opinion-war", AppOpinionWar);

View File

@ -16,7 +16,9 @@ export class AppQuizPlayer extends Component {
this._progress = this.querySelector("[data-quiz-progress]");
this._score = this.querySelector("[data-quiz-score]");
this._timer = this.querySelector("[data-quiz-timer]");
this._slides = Array.from(this.querySelectorAll("[data-quiz-slide]"));
this._bindForms();
if (this._slides.length) this._initSlides();
if (this._hasLimit && this._status === "in_progress") this._startTimer();
}
@ -24,6 +26,46 @@ export class AppQuizPlayer extends Component {
if (this._interval) clearInterval(this._interval);
}
_initSlides() {
const startAt = this._slides.findIndex((slide) => !slide.classList.contains("quiz-question-answered"));
this._buildSlideNav();
this._showSlide(startAt === -1 ? this._slides.length - 1 : startAt);
}
_buildSlideNav() {
const host = this.querySelector("[data-quiz-slides]");
if (!host) return;
const nav = document.createElement("div");
nav.className = "quiz-slide-nav";
this._prevBtn = document.createElement("button");
this._prevBtn.type = "button";
this._prevBtn.className = "btn btn-secondary";
this._prevBtn.textContent = "Previous";
this._prevBtn.addEventListener("click", () => this._showSlide(this._current - 1));
this._counter = document.createElement("span");
this._counter.className = "quiz-slide-counter";
this._nextBtn = document.createElement("button");
this._nextBtn.type = "button";
this._nextBtn.className = "btn btn-secondary";
this._nextBtn.textContent = "Next";
this._nextBtn.addEventListener("click", () => this._showSlide(this._current + 1));
nav.appendChild(this._prevBtn);
nav.appendChild(this._counter);
nav.appendChild(this._nextBtn);
host.before(nav);
}
_showSlide(index) {
if (!this._slides.length) return;
this._current = Math.max(0, Math.min(this._slides.length - 1, index));
this._slides.forEach((slide, i) => {
slide.hidden = i !== this._current;
});
if (this._prevBtn) this._prevBtn.disabled = this._current === 0;
if (this._nextBtn) this._nextBtn.disabled = this._current === this._slides.length - 1;
if (this._counter) this._counter.textContent = `Question ${this._current + 1} of ${this._slides.length}`;
}
_bindForms() {
this.querySelectorAll("[data-quiz-answer-form]").forEach((form) => {
form.addEventListener("submit", (event) => {
@ -133,9 +175,9 @@ export class AppQuizPlayer extends Component {
_advance(form) {
const question = form.closest("[data-quiz-question]");
if (!question) return;
const next = question.nextElementSibling;
if (next && next.scrollIntoView) next.scrollIntoView({ block: "start", behavior: "smooth" });
if (!question || !this._slides.length) return;
const index = this._slides.indexOf(question);
if (index !== -1 && index < this._slides.length - 1) this._showSlide(index + 1);
}
}

View File

@ -15,4 +15,5 @@ import "./AppIsslopRun.js";
import "./AppChat.js";
import "./AppQuizPlayer.js";
import "./AppQuizBuilder.js";
import "./AppOpinionWar.js";
import "./ContainerTerminal.js";

View File

@ -0,0 +1,95 @@
<dp-opinion-war class="war-card" uid="{{ _war.uid }}" topic="public.battle.{{ _war.uid }}" seq="{{ _war.last_seq }}" ends-at="{{ _war.ends_at }}" status="{{ _war.status }}">
<div class="war-head">
<span class="war-head-title"><span class="icon" aria-hidden="true">&#x2694;&#xFE0E;</span> Opinion War</span>
<span class="war-countdown" data-war-countdown>{% if _war.status == 'active' %}Battle ends in {{ _war.ends_in }}{% else %}Battle ended{% endif %}</span>
</div>
<div class="war-bars">
<div class="war-side war-side-a">
<span class="war-side-name">{{ render_title(_war.faction_a) }}</span>
<div class="war-bar war-bar-a"><span class="war-bar-fill" data-war-bar-a style="--war-pct: {{ _war.pct_a }}%;"></span><span class="war-bar-pct" data-war-pct-a>{{ _war.pct_a }}%</span></div>
<span class="war-side-hp"><span data-war-hp-a>{{ "{:,}".format(_war.hp_a) }}</span> HP</span>
</div>
<span class="war-vs" aria-hidden="true">VS</span>
<div class="war-side war-side-b">
<span class="war-side-name">{{ render_title(_war.faction_b) }}</span>
<div class="war-bar war-bar-b"><span class="war-bar-fill" data-war-bar-b style="--war-pct: {{ _war.pct_b }}%;"></span><span class="war-bar-pct" data-war-pct-b>{{ _war.pct_b }}%</span></div>
<span class="war-side-hp"><span data-war-hp-b>{{ "{:,}".format(_war.hp_b) }}</span> HP</span>
</div>
</div>
{% include "_opinion_war_field.html" %}
<div class="war-victory" data-war-victory{% if _war.status != 'resolved' %} hidden{% endif %}>
<span class="war-victory-title" data-war-victory-title>
{%- if _war.winner == 'draw' -%}
It's a draw at {{ "{:,}".format(_war.hp_a) }} HP each
{%- elif _war.winner_label -%}
{{ _war.winner_label }} wins the war!
{%- endif -%}
</span>
</div>
{% if _war.viewer %}
<div class="war-mine" data-war-mine>
<span class="war-mine-faction war-tag-{{ _war.viewer.faction }}">{{ render_title(_war.viewer.faction == 'a' and _war.faction_a or _war.faction_b) }}</span>
<span class="war-mine-stats">Rank #{{ _war.viewer.rank }} &middot; <span data-war-mine-hp>{{ "{:,}".format(_war.viewer.hp) }}</span> HP dealt</span>
</div>
{% endif %}
{% if _war.status == 'active' %}
<div class="war-actions" data-war-actions>
{% if not _war.viewer %}
<form method="POST" action="/battles/{{ _war.uid }}/join" data-war-action="join" class="war-join-form">
<input type="hidden" name="faction" value="a">
<button type="submit" class="btn btn-secondary btn-sm war-join-btn war-join-a"{{ guest_disabled(user) }}>Join {{ render_title(_war.faction_a) }}</button>
</form>
<form method="POST" action="/battles/{{ _war.uid }}/join" data-war-action="join" class="war-join-form">
<input type="hidden" name="faction" value="b">
<button type="submit" class="btn btn-secondary btn-sm war-join-btn war-join-b"{{ guest_disabled(user) }}>Join {{ render_title(_war.faction_b) }}</button>
</form>
{% else %}
<form method="POST" action="/battles/{{ _war.uid }}/fight" data-war-action="fight" class="war-fight-form">
<button type="submit" class="btn btn-primary war-fight-btn" data-war-fight{% if not _war.viewer.can_fight %} disabled data-war-ready-at="{{ _war.viewer.next_fight_at }}"{% endif %}>
{%- if _war.viewer.can_fight %}Fight ({{ _war.fight_cost }} coins){% else %}On cooldown{% endif -%}
</button>
</form>
<form method="POST" action="/battles/{{ _war.uid }}/join" data-war-action="join" data-war-confirm="Switch to {{ _war.viewer.faction == 'a' and _war.faction_b or _war.faction_a }}? Damage you already dealt stays with its faction." class="war-switch-form">
<input type="hidden" name="faction" value="{{ _war.viewer.faction == 'a' and 'b' or 'a' }}">
<button type="submit" class="btn btn-secondary btn-sm war-switch-btn">Switch faction</button>
</form>
{% endif %}
</div>
{% endif %}
{% if _war.top_contributors %}
<div class="war-contributors">
<span class="war-block-label">Top contributors</span>
<ul class="war-contributor-list">
{% for fighter in _war.top_contributors %}
<li class="war-contributor">
{% set _user = fighter %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% set _user = fighter %}{% include "_user_link.html" %}
<span class="war-contributor-hp war-text-{{ fighter.faction }}">{{ "{:,}".format(fighter.hp) }} HP</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="war-events">
<span class="war-block-label">Live events</span>
<ul class="war-ticker" data-war-ticker>
{% for e in _war.recent_events %}
<li class="war-event war-event-{{ e.kind }}"><span class="war-event-dot war-dot-{{ e.faction or 'n' }}" aria-hidden="true"></span><span class="war-event-message">{{ e.message }}</span><span class="war-event-time">{{ dt_ago(e.created_at) }}</span></li>
{% else %}
<li class="war-event war-event-empty">No battle activity yet - be the first to fight!</li>
{% endfor %}
</ul>
</div>
<div class="war-foot">
<a href="/battles" class="war-all-link"><span class="icon" aria-hidden="true">&#x2694;&#xFE0E;</span> View All Battles</a>
{% set _type = "battle" %}{% set _uid = _war.uid %}{% set _owner = _war.author.uid %}{% set _owner_name = _war.author.username %}{% set _class = "war-foot-btn" %}{% include "_report_button.html" %}
</div>
</dp-opinion-war>

View File

@ -0,0 +1,14 @@
{% set _strength_a = 1 + (_war.pct_a // 30) %}
{% set _strength_b = 1 + (_war.pct_b // 30) %}
<div class="war-field{% if _war.leader %} war-field-lead-{{ _war.leader }}{% endif %}" data-war-field aria-hidden="true">
<div class="war-castle war-castle-a"><span class="war-px war-castle-px"></span><span class="war-px war-flag war-flag-a"></span></div>
<div class="war-troops war-troops-a">
{% for _ in range(_strength_a) %}<span class="war-soldier war-soldier-a"><span class="war-px war-soldier-px"></span></span>{% endfor %}
</div>
<div class="war-campfire"><span class="war-px war-fire-logs"></span><span class="war-px war-fire-flame"></span></div>
<div class="war-troops war-troops-b">
{% for _ in range(_strength_b) %}<span class="war-soldier war-soldier-b"><span class="war-px war-soldier-px"></span></span>{% endfor %}
</div>
<div class="war-castle war-castle-b"><span class="war-px war-castle-px"></span><span class="war-px war-flag war-flag-b"></span></div>
<div class="war-ground"></div>
</div>

View File

@ -34,6 +34,10 @@
{% set _poll = item.poll %}{% include "_poll.html" %}
{% endif %}
{% if item.war %}
{% set _war = item.war %}{% include "_opinion_war.html" %}
{% endif %}
<div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">

View File

@ -41,6 +41,19 @@
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-war-toggle><span class="icon">&#x2694;&#xFE0F;</span> <span data-war-toggle-label>Start Opinion War</span></button>
</div>
<div class="war-builder" data-war-builder hidden>
<p class="war-builder-hint">Two factions battle for 7 days. Members pick a side and fight once a day.</p>
<div class="war-builder-row">
<input type="text" name="war_faction_a" maxlength="30" placeholder="Faction A (e.g. Tabs)" aria-label="Faction A name" disabled>
<span class="war-builder-vs">VS</span>
<input type="text" name="war_faction_b" maxlength="30" placeholder="Faction B (e.g. Spaces)" aria-label="Faction B name" disabled>
</div>
<p class="war-builder-error" data-war-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>

View File

@ -52,6 +52,7 @@
<link rel="stylesheet" href="{{ static_url('/static/css/lightbox.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/media.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/engagement.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/opinionwar.css') }}">
{% block extra_head %}{% endblock %}
{{ custom_css_tag(request) }}
{{ extra_head_tag() }}
@ -74,6 +75,7 @@
<a href="/gists" class="topnav-link {{ nav_active(request, '/gists') }}"><span class="icon">đź“„</span> Gists</a>
<a href="/projects" class="topnav-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/quizzes" class="topnav-link {{ nav_active(request, '/quizzes') }}"><span class="icon">đź§©</span> Quizzes</a>
<a href="/battles" class="topnav-link {{ nav_active(request, '/battles') }}"><span class="icon">&#x2694;&#xFE0E;</span> Battles</a>
{% if user %}<a href="/game" class="topnav-link {{ nav_active(request, '/game') }}"><span class="icon">🌱</span> Farm</a>{% endif %}
</div>
<div class="topnav-right">
@ -144,6 +146,7 @@
<a href="/gists" class="topnav-mobile-link {{ nav_active(request, '/gists') }}"><span class="icon">đź“„</span> Gists</a>
<a href="/projects" class="topnav-mobile-link {{ nav_active(request, '/projects') }}"><span class="icon">🚀</span> Projects</a>
<a href="/quizzes" class="topnav-mobile-link {{ nav_active(request, '/quizzes') }}"><span class="icon">đź§©</span> Quizzes</a>
<a href="/battles" class="topnav-mobile-link {{ nav_active(request, '/battles') }}"><span class="icon">&#x2694;&#xFE0E;</span> Battles</a>
<a href="/leaderboard" class="topnav-mobile-link {{ nav_active(request, '/leaderboard') }}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}<a href="/game" class="topnav-mobile-link {{ nav_active(request, '/game') }}"><span class="icon">🌱</span> Farm</a>{% endif %}
<div class="topnav-mobile-divider"></div>

View File

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
{% endblock %}
{% block content %}
<h1 class="sr-only">Opinion Wars</h1>
<div class="feed-layout">
<aside class="sidebar-card" role="complementary" aria-label="Battle filters">
{% set _action = "/battles" %}{% set _placeholder = "Search battles..." %}{% set _hidden = {"filter": current_filter} %}{% include "_sidebar_search.html" %}
<div class="sidebar-heading">Filters</div>
<div class="sidebar-nav">
<a href="/battles?filter=active" class="sidebar-link {% if current_filter == 'active' %}active{% endif %}">
<span class="icon">&#x2694;&#xFE0E;</span> Active <span class="war-filter-count">{{ counts.get('active', 0) }}</span>
</a>
<a href="/battles?filter=ended" class="sidebar-link {% if current_filter == 'ended' %}active{% endif %}">
<span class="icon">&#x1F3C6;</span> Ended <span class="war-filter-count">{{ counts.get('ended', 0) }}</span>
</a>
{% if user %}
<a href="/battles?filter=mine" class="sidebar-link {% if current_filter == 'mine' %}active{% endif %}">
<span class="icon">&#x1F6E1;&#xFE0E;</span> Mine <span class="war-filter-count">{{ counts.get('mine', 0) }}</span>
</a>
{% endif %}
</div>
{% if not user %}
<div class="sidebar-section">
<a href="/auth/login" class="sidebar-link"><span class="icon">🔑</span> Log in to fight</a>
</div>
{% endif %}
</aside>
<div class="feed-main">
<div class="feed-posts" role="feed" aria-label="Opinion Wars">
{% for battle in battles %}
<article class="post-card war-listing-item">
<div class="war-listing-head">
<a href="{{ battle.post_url }}" class="post-title-link"><h3 class="post-title">{{ render_title(battle.post_title) if battle.post_title else 'View post' }}</h3></a>
<span class="war-listing-meta">by {% set _user = battle.author %}{% include "_user_link.html" %} &middot; {{ dt_ago(battle.created_at) }}</span>
</div>
{% set _war = battle %}{% include "_opinion_war.html" %}
</article>
{% else %}
<div class="empty-state">No battles here yet. Start an Opinion War from the Create New Post form.</div>
{% endfor %}
</div>
{% set pagination_query = "filter=" ~ current_filter ~ "&" ~ ("search=" ~ search ~ "&" if search else "") %}
{% include "_pagination.html" %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,51 @@
<div class="docs-content" data-render>
# Opinion Wars
An Opinion War is a week-long battle between two factions, attached to a post. It is the
place to settle the eternal debates - tabs versus spaces, Python versus Rust - by showing
up every day and fighting for your side.
## Starting a war
Open **Create New Post** on the feed and press **Start Opinion War** (next to **Add
poll**). Name the two factions, write your post, and publish. The battle starts
immediately and runs for exactly **7 days**. A post carries at most one war, and it can
only be started when the post is created.
## Joining and fighting
Any signed-in member picks a side with the **Join** buttons on the battle card. Once
joined, the **Fight** button becomes yours:
- A fight costs **25 Code Farm coins** - earn them by playing [Code Farm]({{ base }}/game).
- Each fight deals damage for your faction: a base of 100 HP plus 10 HP per site level,
capped at level 20. A brand new member deals 110 HP; a veteran caps out at 300 HP.
There is no randomness - showing up daily beats everything else.
- You can fight **once every 24 hours** per battle. A notification tells you when your
next fight is ready.
You may switch factions at any time, but damage you already dealt stays with the faction
it was dealt to - defecting does not move your HP.
## Winning
When the week is over, the faction with more HP wins. Every fighter who fought at least
once earns XP, the winning side earns a bonus, and the single top damage dealer earns an
extra bonus. Equal totals are a draw with participation XP only. The card freezes into a
victory banner with the final totals and top contributors.
The battle card shows everything live: the HP bars, the countdown, your faction and rank,
the top contributors, and a ticker of joins, defections, fights and lead changes.
## The battle listing
Every battle - active and ended - is listed at [{{ base }}/battles]({{ base }}/battles),
searchable by faction name or creator, with an **Active**, **Ended** and **Mine** filter.
## The API
The full REST surface is documented in the [Opinion Wars API group]({{ base }}/docs/battles.html):
list battles, read one battle's state and event log, join a faction, and fight. Devii can
do all of it for you conversationally - it will ask for confirmation before joining or
spending your coins.
</div>

View File

@ -44,6 +44,10 @@
{% set _poll = poll %}{% include "_poll.html" %}
{% endif %}
{% if war %}
{% set _war = war %}{% include "_opinion_war.html" %}
{% endif %}
<div class="post-detail-actions">
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}

View File

@ -69,7 +69,7 @@
</article>
{% if leaderboard %}
<section class="quiz-leaderboard" aria-label="Quiz leaderboard">
<section id="leaderboard" class="quiz-leaderboard" aria-label="Quiz leaderboard">
<h2>Leaderboard</h2>
<ol class="quiz-leaderboard-list">
{% for entry in leaderboard %}

View File

@ -46,7 +46,7 @@
<form method="POST" action="{{ quiz.url }}/attempts" class="inline-form">
<button type="submit" class="btn btn-primary">Play again</button>
</form>
<a href="{{ quiz.url }}/leaderboard" class="btn btn-secondary">Leaderboard</a>
<a href="{{ quiz.url }}#leaderboard" class="btn btn-secondary">Leaderboard</a>
<a href="/quizzes" class="btn btn-secondary">All quizzes</a>
</div>
</div>

View File

@ -21,6 +21,7 @@ The top-nav bell and Messages link carry `data-counter="notifications"` / `data-
| `level` | Reach a new level | `utils.py` `award_xp` | `new_level > current_level` |
| `badge` | Earn any badge | `utils.py` `notify_badge` | First grant only (`award_badge` returns `True`) |
| `issue` | Issue filed/replied/status | `issues.py`, `services/gitea`, `services/jobs/issue_create_service.py` | Reporter or admins |
| `battle` | Opinion War lead change / result / fight-ready | `services/opinionwar/store.py` (lead change + result), `services/opinionwar/service.py` (cooldown sweep) | Fighters of that war; lead change excludes the actor |
The `moderation` type carries both faces of the safety layer: the acknowledgement (with the published response window) sent to a reporter the moment they file, and the **statement of reasons** sent to a user whose content or account was actioned. Both are ordinary `create_notification` calls through the single funnel, so a user can turn the channel off per preference like any other type - what they cannot turn off is the decision itself.

View File

@ -117,6 +117,9 @@ from devplacepy.utils.rewards import (
XP_QUIZ,
XP_QUIZ_PUBLISH,
XP_QUIZ_COMPLETE,
XP_BATTLE_PART,
XP_BATTLE_WIN,
XP_BATTLE_TOP,
level_for_xp,
award_xp,
_COUNT_MILESTONES,

View File

@ -55,6 +55,10 @@ BADGE_CATALOG = {
"Researcher": {"icon": "🔬", "description": "Ran a DeepSearch investigation", "group": "Explorer"},
"Deep Diver": {"icon": "🌊", "description": "Ran 10 DeepSearch investigations", "group": "Explorer"},
"Container Captain": {"icon": "📦", "description": "Created a container instance", "group": "Explorer"},
"Instigator": {"icon": "🎺", "description": "Started an Opinion War", "group": "Engagement"},
"First Blood": {"icon": "âš”", "description": "Fought in an Opinion War", "group": "Engagement"},
"War Veteran": {"icon": "🛡", "description": "Fought 50 Opinion War battles", "group": "Engagement"},
"Champion": {"icon": "🏅", "description": "Won an Opinion War", "group": "Engagement"},
"Messenger": {"icon": "✉", "description": "Sent your first direct message", "group": "Community"},
"Chatterbox": {"icon": "📨", "description": "Sent 100 direct messages", "group": "Community"},
"Bookmarker": {"icon": "đź”–", "description": "Bookmarked your first item", "group": "Engagement"},

View File

@ -22,6 +22,9 @@ XP_FOLLOW = 5
XP_QUIZ = 8
XP_QUIZ_PUBLISH = 12
XP_QUIZ_COMPLETE = 4
XP_BATTLE_PART = 5
XP_BATTLE_WIN = 15
XP_BATTLE_TOP = 10
def level_for_xp(xp: int) -> int:
@ -175,6 +178,9 @@ ACHIEVEMENTS = {
"docs.read": [(1, "Curious"), (5, "Studious"), (15, "Scholar")],
"devii": [(1, "AI Curious"), (25, "AI Whisperer")],
"fork": [(1, "First Fork"), (5, "Forker")],
"battle_create": [(1, "Instigator")],
"battle_fight": [(1, "First Blood"), (50, "War Veteran")],
"battle_win": [(1, "Champion")],
"zip": [(1, "Archivist")],
"seo": [(1, "SEO Auditor")],
"isslop": [(1, "Slop Hunter")],

View File

@ -328,6 +328,11 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `poll.vote.cast` | `routers/polls.py` |
| `poll.vote.change` | `routers/polls.py` |
| `poll.vote.clear` | `routers/polls.py` |
| `battle.create` | `services/opinionwar/store.py` |
| `battle.join` | `services/opinionwar/store.py` |
| `battle.switch` | `services/opinionwar/store.py` |
| `battle.fight` | `services/opinionwar/store.py` |
| `battle.resolve` | `services/opinionwar/store.py` |
| `reaction.add` | `routers/reactions.py` |
| `reaction.remove` | `routers/reactions.py` |
| `vote.comment.clear` | `content.py` |

View File

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)

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

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

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

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"

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

156
tests/e2e/battles/index.py Normal file
View File

@ -0,0 +1,156 @@
# 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()
expect(card.locator("[data-war-ticker]")).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()

View File

@ -68,6 +68,33 @@ def test_starting_opens_the_player(alice):
page.locator(".quiz-hud").wait_for(state="visible")
def test_the_player_shows_one_question_at_a_time(alice):
page, _ = alice
slug = _author_quiz("Player one at a time quiz")
_start(page, slug)
questions = page.locator(".quiz-question")
assert questions.nth(0).is_visible()
assert not questions.nth(1).is_visible()
page.locator(".quiz-slide-nav button:has-text('Next')").click()
assert not questions.nth(0).is_visible()
assert questions.nth(1).is_visible()
page.locator(".quiz-slide-nav button:has-text('Previous')").click()
assert questions.nth(0).is_visible()
assert not questions.nth(1).is_visible()
def test_answering_advances_to_the_next_question(alice):
page, _ = alice
slug = _author_quiz("Player advance quiz")
_start(page, slug)
questions = page.locator(".quiz-question")
questions.nth(0).locator("input[type='radio']").last.check()
questions.nth(0).locator("button:has-text('Submit answer')").click()
questions.nth(0).locator(".quiz-grade").wait_for(state="visible")
questions.nth(1).wait_for(state="visible")
assert not questions.nth(0).is_visible()
def test_the_player_renders_a_real_form_per_question(alice):
page, _ = alice
slug = _author_quiz("Player forms quiz")
@ -129,7 +156,7 @@ def test_an_answered_question_cannot_be_resubmitted(alice):
def test_a_numeric_question_takes_a_typed_answer(alice):
page, _ = alice
slug = _author_quiz("Player numeric quiz")
slug = _author_quiz("Player numeric quiz", questions=(NUMERIC,))
_start(page, slug)
question = page.locator(".quiz-question-numeric").first
question.locator("input[name='answer_text']").fill("1024")

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) == ""