Compare commits
7 Commits
PasteImage
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 055c7bcd07 | |||
| 50baf9d6f1 | |||
| 80956ce0f4 | |||
| 2f26dbb1e7 | |||
| 516219513a | |||
| 8856c38b4d | |||
| 08d370b020 |
@ -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` |
|
||||
|
||||
20
README.md
20
README.md
@ -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.
|
||||
|
||||
@ -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`"
|
||||
|
||||
@ -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"],
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)"},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
]
|
||||
|
||||
149
devplacepy/docs_api/groups/battles.py
Normal file
149
devplacepy/docs_api/groups/battles.py
Normal 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}},
|
||||
),
|
||||
],
|
||||
}
|
||||
@ -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."],
|
||||
),
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) |
|
||||
|
||||
129
devplacepy/routers/battles.py
Normal file
129
devplacepy/routers/battles.py
Normal 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})
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
82
devplacepy/schemas/battles.py
Normal file
82
devplacepy/schemas/battles.py
Normal 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 = ""
|
||||
@ -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] = []
|
||||
|
||||
@ -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")
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
85
devplacepy/services/devii/actions/catalog/battles.py
Normal file
85
devplacepy/services/devii/actions/catalog/battles.py
Normal 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()),
|
||||
),
|
||||
)
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
134
devplacepy/services/opinionwar/CLAUDE.md
Normal file
134
devplacepy/services/opinionwar/CLAUDE.md
Normal 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/`.
|
||||
6
devplacepy/services/opinionwar/__init__.py
Normal file
6
devplacepy/services/opinionwar/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import rules, store
|
||||
from .store import WarError
|
||||
|
||||
__all__ = ["rules", "store", "WarError"]
|
||||
111
devplacepy/services/opinionwar/rules.py
Normal file
111
devplacepy/services/opinionwar/rules.py
Normal 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"
|
||||
90
devplacepy/services/opinionwar/service.py
Normal file
90
devplacepy/services/opinionwar/service.py
Normal 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}
|
||||
794
devplacepy/services/opinionwar/store.py
Normal file
794
devplacepy/services/opinionwar/store.py
Normal 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
|
||||
@ -29,6 +29,23 @@
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.attachment-gallery.single .attachment-gallery-item:has(.gallery-thumb):hover {
|
||||
transform: none;
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.attachment-gallery.single .gallery-thumb {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-gallery-item:has(.non-image) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
||||
714
devplacepy/static/css/opinionwar.css
Normal file
714
devplacepy/static/css/opinionwar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -92,7 +92,11 @@ File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `
|
||||
|
||||
**Ingesting a file from a URL.** `store_attachment_from_url(url, user_uid, filename=None)` (async, in `attachments.py`) is the remote counterpart to `store_attachment`: it downloads the URL on the server through `fetch_remote_file()` - SSRF-guarded (`_guard_public_url` resolves the host and refuses private/loopback/reserved/multicast addresses, mirroring the Devii fetch guard) and size-capped (streams, aborting once `_get_max_upload_bytes()` is exceeded) - resolves a filename from the URL path or the response `Content-Type` (`MIME_TO_EXT`), then calls `store_attachment()` so the bytes land in the **exact same** pipeline (validation, thumbnailing, DB row). It raises `RemoteFetchError(message, status)` which the route maps to an HTTP status. It is exposed at `POST /uploads/upload-url` (`UploadUrlForm{url, filename?}`, `require_user_api`) and as the Devii catalog action `attach_url` (handler `http`, `requires_auth=True`); both return the same record as `/uploads/upload`. The returned `uid` binds to a resource the same way as any upload - via `attachment_uids` at create/edit time - so attaching a remote image is just `attach_url` then `create_post`/`create_project`/etc. with that uid. Do not re-download remote files in a router; reuse this helper so the guard and size cap stay in one place.
|
||||
|
||||
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
|
||||
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once.
|
||||
|
||||
**Every caller MUST bind `attachments` before including the partial** - `{% set attachments = item.get('attachments', []) %}` or `{% with attachments=... %}`. The partial iterates the bare name `attachments`, so a caller that only guards on `{% if item.attachments %}` and includes without binding renders the gallery from whatever `attachments` happens to be in the surrounding page context. This is not theoretical: `_post_card.html` did exactly that, so **feed and profile cards silently rendered an empty gallery for every post that had an image**, and on a project page (where `project_detail.html` sets `attachments` at template scope for the project's own files) a devlog card would have rendered the *project's* attachments as if they were the post's. Guarded by `tests/e2e/feed.py::test_feed_card_shows_the_post_image`.
|
||||
|
||||
**A lone attachment is a hero, not a chip.** When the gallery holds exactly one item the partial adds a `single` class, and `attachments.css` widens that item to the full content column (`max-height: 480px`, `object-fit: contain`, no hover scale) instead of the 240x200 chip a multi-item gallery uses. **The `single` branch must serve `att['url']`, never `thumbnail_url`** - a thumbnail is 200px on its longest side, so blowing it up to the column width renders visibly blurry. That is the whole reason the src is a conditional rather than "thumbnail when one exists". Because the partial is shared, this applies everywhere at once: post cards, post detail, comments, gists, projects and chat bubbles. Animated GIFs never had a thumbnail to begin with (`THUMBNAIL_EXTENSIONS` excludes `.gif`, so animation survives), which means they already took the original-file path and simply render larger now. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
|
||||
|
||||
Media is served **inline** (not forced-download) for known-safe types only. The set `INLINE_MEDIA_EXTENSIONS` in `main.py` (`UploadStaticFiles`) and the matching `map $uri $upload_disposition` in `nginx/nginx.conf.template` must stay in sync: images/video/audio -> `inline` (so `<video>` plays and seeks via Range), everything else -> `attachment`. SVG is deliberately excluded from both (stored-XSS defense). `ContentRenderer.js` embeds direct video URLs typed into content via `videoExtRe`, mirroring its image handling.
|
||||
|
||||
|
||||
106
devplacepy/static/js/WarComposer.js
Normal file
106
devplacepy/static/js/WarComposer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
254
devplacepy/static/js/components/AppOpinionWar.js
Normal file
254
devplacepy/static/js/components/AppOpinionWar.js
Normal 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);
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,4 +15,5 @@ import "./AppIsslopRun.js";
|
||||
import "./AppChat.js";
|
||||
import "./AppQuizPlayer.js";
|
||||
import "./AppQuizBuilder.js";
|
||||
import "./AppOpinionWar.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
<div class="attachment-gallery">
|
||||
{% set _single = attachments|length == 1 %}
|
||||
<div class="attachment-gallery{% if _single %} single{% endif %}">
|
||||
{% for att in attachments %}
|
||||
<div class="attachment-gallery-item">
|
||||
{% if att.get('is_image') and att.get('thumbnail_url') %}
|
||||
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_image') %}
|
||||
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% if att.get('is_image') %}
|
||||
<img src="{{ att['url'] if _single or not att.get('thumbnail_url') else att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_video') %}
|
||||
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||
{% elif att.get('is_audio') %}
|
||||
|
||||
95
devplacepy/templates/_opinion_war.html
Normal file
95
devplacepy/templates/_opinion_war.html
Normal 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">⚔︎</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 }} · <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">⚔︎</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>
|
||||
14
devplacepy/templates/_opinion_war_field.html
Normal file
14
devplacepy/templates/_opinion_war_field.html
Normal 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>
|
||||
@ -25,7 +25,8 @@
|
||||
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if item.attachments %}
|
||||
{% set attachments = item.get('attachments', []) %}
|
||||
{% if attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
{% endif %}
|
||||
|
||||
@ -33,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">
|
||||
|
||||
@ -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">⚔️</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>
|
||||
|
||||
@ -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">⚔︎</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">⚔︎</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>
|
||||
|
||||
53
devplacepy/templates/battles.html
Normal file
53
devplacepy/templates/battles.html
Normal 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">⚔︎</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">🏆</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">🛡︎</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" %} · {{ 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 %}
|
||||
51
devplacepy/templates/docs/opinion-wars.html
Normal file
51
devplacepy/templates/docs/opinion-wars.html
Normal 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>
|
||||
@ -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" %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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")],
|
||||
|
||||
@ -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` |
|
||||
|
||||
0
tests/api/battles/__init__.py
Normal file
0
tests/api/battles/__init__.py
Normal file
78
tests/api/battles/_helpers.py
Normal file
78
tests/api/battles/_helpers.py
Normal file
@ -0,0 +1,78 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
JSON = {"Accept": "application/json"}
|
||||
_counter_battles = [0]
|
||||
|
||||
|
||||
def _session_battles():
|
||||
_counter_battles[0] += 1
|
||||
name = f"war{int(time.time() * 1000)}{_counter_battles[0]}"
|
||||
s = requests.Session()
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
"birth_date": "1990-01-01",
|
||||
"accept_terms": "1",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
return s, name
|
||||
|
||||
|
||||
def _create_war_post(session, title, faction_a="Tabs", faction_b="Spaces"):
|
||||
r = session.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={
|
||||
"content": "Opinion War host post content for tests.",
|
||||
"title": title,
|
||||
"topic": "question",
|
||||
"war_faction_a": faction_a,
|
||||
"war_faction_b": faction_b,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
slug = r.headers["location"].split("/posts/")[-1]
|
||||
refresh_snapshot()
|
||||
post = get_table("posts").find_one(slug=slug)
|
||||
war = get_table("opinion_wars").find_one(post_uid=post["uid"])
|
||||
return post, war
|
||||
|
||||
|
||||
def _war_row(war_uid):
|
||||
refresh_snapshot()
|
||||
return get_table("opinion_wars").find_one(uid=war_uid)
|
||||
|
||||
|
||||
def _fighter_row(war_uid, user_uid):
|
||||
refresh_snapshot()
|
||||
return get_table("opinion_war_fighters").find_one(
|
||||
war_uid=war_uid, user_uid=user_uid
|
||||
)
|
||||
|
||||
|
||||
def _user_row(username):
|
||||
refresh_snapshot()
|
||||
return get_table("users").find_one(username=username)
|
||||
|
||||
|
||||
def _join(session, war_uid, faction):
|
||||
return session.post(
|
||||
f"{BASE_URL}/battles/{war_uid}/join",
|
||||
data={"faction": faction},
|
||||
headers=JSON,
|
||||
)
|
||||
|
||||
|
||||
def _fight(session, war_uid):
|
||||
return session.post(f"{BASE_URL}/battles/{war_uid}/fight", headers=JSON)
|
||||
165
tests/api/battles/actions.py
Normal file
165
tests/api/battles/actions.py
Normal file
@ -0,0 +1,165 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import requests
|
||||
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
from devplacepy.services.opinionwar import rules
|
||||
from tests.api.battles._helpers import (
|
||||
JSON,
|
||||
_create_war_post,
|
||||
_fight,
|
||||
_fighter_row,
|
||||
_join,
|
||||
_session_battles,
|
||||
_user_row,
|
||||
_war_row,
|
||||
)
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def _warp_cooldown(war_uid, user_uid):
|
||||
fighter = _fighter_row(war_uid, user_uid)
|
||||
warped = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat()
|
||||
get_table("opinion_war_fighters").update(
|
||||
{"uid": fighter["uid"], "last_fight_at": warped}, ["uid"]
|
||||
)
|
||||
|
||||
|
||||
def test_join_and_switch(app_server):
|
||||
s, name = _session_battles()
|
||||
_, war = _create_war_post(s, f"join-{int(time.time() * 1000)}")
|
||||
r = _join(s, war["uid"], "a")
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["data"]["war"]["viewer"]["faction"] == "a"
|
||||
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
|
||||
assert fighter["faction"] == "a"
|
||||
r = _join(s, war["uid"], "b")
|
||||
assert r.json()["data"]["war"]["viewer"]["faction"] == "b"
|
||||
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
|
||||
assert fighter["faction"] == "b"
|
||||
|
||||
|
||||
def test_join_invalid_faction_422(app_server):
|
||||
s, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"badfaction-{int(time.time() * 1000)}")
|
||||
r = s.post(
|
||||
f"{BASE_URL}/battles/{war['uid']}/join",
|
||||
data={"faction": "c"},
|
||||
headers=JSON,
|
||||
)
|
||||
assert r.status_code in (400, 422)
|
||||
|
||||
|
||||
def test_join_requires_login(app_server):
|
||||
s, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"anon-{int(time.time() * 1000)}")
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/battles/{war['uid']}/join",
|
||||
data={"faction": "a"},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code in (303, 401)
|
||||
|
||||
|
||||
def test_fight_spends_coins_and_deals_level_damage(app_server):
|
||||
s, name = _session_battles()
|
||||
_, war = _create_war_post(s, f"fight-{int(time.time() * 1000)}")
|
||||
_join(s, war["uid"], "a")
|
||||
r = _fight(s, war["uid"])
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
user = _user_row(name)
|
||||
expected = rules.damage_for(user.get("level"))
|
||||
assert payload["data"]["damage"] == expected
|
||||
assert payload["data"]["war"]["hp_a"] == expected
|
||||
war_row = _war_row(war["uid"])
|
||||
assert int(war_row["hp_a"]) == expected
|
||||
refresh_snapshot()
|
||||
farm = get_table("game_farms").find_one(user_uid=user["uid"])
|
||||
from devplacepy.services.game import economy
|
||||
|
||||
assert int(farm["coins"]) == economy.STARTING_COINS - rules.FIGHT_COST_COINS
|
||||
|
||||
|
||||
def test_fight_cooldown_refused(app_server):
|
||||
s, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"cooldown-{int(time.time() * 1000)}")
|
||||
_join(s, war["uid"], "a")
|
||||
assert _fight(s, war["uid"]).status_code == 200
|
||||
r = _fight(s, war["uid"])
|
||||
assert r.status_code == 400
|
||||
assert "24 hours" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_fight_after_warped_cooldown_lands(app_server):
|
||||
s, name = _session_battles()
|
||||
_, war = _create_war_post(s, f"warp-{int(time.time() * 1000)}")
|
||||
_join(s, war["uid"], "b")
|
||||
assert _fight(s, war["uid"]).status_code == 200
|
||||
_warp_cooldown(war["uid"], _user_row(name)["uid"])
|
||||
r = _fight(s, war["uid"])
|
||||
assert r.status_code == 200
|
||||
fighter = _fighter_row(war["uid"], _user_row(name)["uid"])
|
||||
assert int(fighter["fight_count"]) == 2
|
||||
|
||||
|
||||
def test_fight_without_coins_refused_and_cooldown_kept(app_server):
|
||||
s, name = _session_battles()
|
||||
_, war = _create_war_post(s, f"broke-{int(time.time() * 1000)}")
|
||||
_join(s, war["uid"], "a")
|
||||
assert _fight(s, war["uid"]).status_code == 200
|
||||
user = _user_row(name)
|
||||
_warp_cooldown(war["uid"], user["uid"])
|
||||
refresh_snapshot()
|
||||
farm = get_table("game_farms").find_one(user_uid=user["uid"])
|
||||
get_table("game_farms").update({"uid": farm["uid"], "coins": 0}, ["uid"])
|
||||
r = _fight(s, war["uid"])
|
||||
assert r.status_code == 400
|
||||
assert "coins" in r.json()["error"]["message"]
|
||||
fighter = _fighter_row(war["uid"], user["uid"])
|
||||
assert int(fighter["fight_count"]) == 1
|
||||
r = _fight(s, war["uid"])
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_fight_before_join_refused(app_server):
|
||||
s, _ = _session_battles()
|
||||
other, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"nojoin-{int(time.time() * 1000)}")
|
||||
r = _fight(other, war["uid"])
|
||||
assert r.status_code == 400
|
||||
assert "Join" in r.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_switch_keeps_dealt_damage(app_server):
|
||||
s, name = _session_battles()
|
||||
_, war = _create_war_post(s, f"defect-{int(time.time() * 1000)}")
|
||||
_join(s, war["uid"], "a")
|
||||
assert _fight(s, war["uid"]).status_code == 200
|
||||
r = _join(s, war["uid"], "b")
|
||||
war_state = r.json()["data"]["war"]
|
||||
user = _user_row(name)
|
||||
expected = rules.damage_for(user.get("level"))
|
||||
assert war_state["hp_a"] == expected
|
||||
assert war_state["hp_b"] == 0
|
||||
fighter = _fighter_row(war["uid"], user["uid"])
|
||||
assert fighter["faction"] == "b"
|
||||
assert int(fighter["hp_a"]) == expected
|
||||
|
||||
|
||||
def test_actions_on_resolved_war_refused(app_server):
|
||||
s, _ = _session_battles()
|
||||
other, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"over-{int(time.time() * 1000)}")
|
||||
_join(s, war["uid"], "a")
|
||||
past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
|
||||
get_table("opinion_wars").update({"uid": war["uid"], "ends_at": past}, ["uid"])
|
||||
r = _fight(s, war["uid"])
|
||||
assert r.status_code == 400
|
||||
r = _join(other, war["uid"], "b")
|
||||
assert r.status_code == 400
|
||||
43
tests/api/battles/events.py
Normal file
43
tests/api/battles/events.py
Normal file
@ -0,0 +1,43 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from tests.api.battles._helpers import _create_war_post, _fight, _join, _session_battles
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_events_ordered_and_incremental(app_server):
|
||||
s, _ = _session_battles()
|
||||
other, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"events-{int(time.time() * 1000)}")
|
||||
_join(s, war["uid"], "a")
|
||||
_join(other, war["uid"], "b")
|
||||
_fight(s, war["uid"])
|
||||
|
||||
r = requests.get(f"{BASE_URL}/battles/{war['uid']}/events")
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["status"] == "active"
|
||||
seqs = [event["seq"] for event in payload["events"]]
|
||||
assert seqs == sorted(seqs)
|
||||
kinds = [event["kind"] for event in payload["events"]]
|
||||
assert kinds[:3] == ["join", "join", "fight"]
|
||||
assert "lead_change" in kinds
|
||||
|
||||
fight_event = next(e for e in payload["events"] if e["kind"] == "fight")
|
||||
assert fight_event["faction"] == "a"
|
||||
assert fight_event["hp_a"] > 0
|
||||
|
||||
after = seqs[1]
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/battles/{war['uid']}/events", params={"after": after}
|
||||
)
|
||||
replay = [event["seq"] for event in r.json()["events"]]
|
||||
assert replay == [seq for seq in seqs if seq > after]
|
||||
|
||||
|
||||
def test_events_unknown_battle_404(app_server):
|
||||
r = requests.get(f"{BASE_URL}/battles/nope/events")
|
||||
assert r.status_code == 404
|
||||
73
tests/api/battles/index.py
Normal file
73
tests/api/battles/index.py
Normal file
@ -0,0 +1,73 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from tests.api.battles._helpers import JSON, _create_war_post, _session_battles
|
||||
from tests.conftest import BASE_URL
|
||||
|
||||
|
||||
def test_listing_renders_battle_card(app_server):
|
||||
s, _ = _session_battles()
|
||||
_create_war_post(s, f"listing-{int(time.time() * 1000)}", "Vim", "Emacs")
|
||||
r = s.get(f"{BASE_URL}/battles")
|
||||
assert r.status_code == 200
|
||||
assert "dp-opinion-war" in r.text
|
||||
assert "Vim" in r.text
|
||||
|
||||
|
||||
def test_listing_json_shape(app_server):
|
||||
s, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"json-{int(time.time() * 1000)}")
|
||||
r = s.get(f"{BASE_URL}/battles", headers=JSON)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["current_filter"] == "active"
|
||||
assert set(data["counts"]) == {"active", "ended", "mine"}
|
||||
uids = [battle["uid"] for battle in data["battles"]]
|
||||
assert war["uid"] in uids
|
||||
battle = next(b for b in data["battles"] if b["uid"] == war["uid"])
|
||||
assert battle["faction_a"] == "Tabs"
|
||||
assert battle["pct_a"] + battle["pct_b"] == 100
|
||||
assert battle["fight_cost"] > 0
|
||||
|
||||
|
||||
def test_listing_guest_access(app_server):
|
||||
s, _ = _session_battles()
|
||||
_create_war_post(s, f"guest-{int(time.time() * 1000)}")
|
||||
r = requests.get(f"{BASE_URL}/battles")
|
||||
assert r.status_code == 200
|
||||
assert "dp-opinion-war" in r.text
|
||||
|
||||
|
||||
def test_listing_search_matches_faction(app_server):
|
||||
s, _ = _session_battles()
|
||||
marker = f"Zx{int(time.time() * 1000) % 100000}"
|
||||
_, war = _create_war_post(s, f"search-{marker}", marker, "Others")
|
||||
r = s.get(f"{BASE_URL}/battles", params={"search": marker}, headers=JSON)
|
||||
uids = [battle["uid"] for battle in r.json()["battles"]]
|
||||
assert uids == [war["uid"]]
|
||||
|
||||
|
||||
def test_listing_invalid_filter_falls_back(app_server):
|
||||
s, _ = _session_battles()
|
||||
r = s.get(f"{BASE_URL}/battles", params={"filter": "bogus"}, headers=JSON)
|
||||
assert r.json()["current_filter"] == "active"
|
||||
|
||||
|
||||
def test_battle_state_json(app_server):
|
||||
s, _ = _session_battles()
|
||||
_, war = _create_war_post(s, f"state-{int(time.time() * 1000)}")
|
||||
r = requests.get(f"{BASE_URL}/battles/{war['uid']}")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["uid"] == war["uid"]
|
||||
assert data["status"] == "active"
|
||||
assert data["viewer"] is None
|
||||
assert data["ends_in"]
|
||||
|
||||
|
||||
def test_battle_state_unknown_uid_404(app_server):
|
||||
r = requests.get(f"{BASE_URL}/battles/does-not-exist", headers=JSON)
|
||||
assert r.status_code == 404
|
||||
94
tests/api/battles/resolve.py
Normal file
94
tests/api/battles/resolve.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -344,6 +344,23 @@ def paste_image(page, selector, name="pasted.png"):
|
||||
)
|
||||
|
||||
|
||||
def create_post_with_files(page, content, files, expected_count=1):
|
||||
from playwright.sync_api import expect
|
||||
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
page.locator(".feed-fab").first.click()
|
||||
page.fill("#post-content", content)
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-input").first.set_input_files(
|
||||
files
|
||||
)
|
||||
expect(
|
||||
page.locator("#create-post-modal dp-upload .dp-upload-count").first
|
||||
).to_have_text(f"({expected_count})", timeout=15000)
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
|
||||
|
||||
def assert_share_copies(page, expected_fragment):
|
||||
from playwright.sync_api import expect
|
||||
|
||||
|
||||
0
tests/e2e/battles/__init__.py
Normal file
0
tests/e2e/battles/__init__.py
Normal file
156
tests/e2e/battles/index.py
Normal file
156
tests/e2e/battles/index.py
Normal 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()
|
||||
@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from tests.conftest import BASE_URL, paste_image
|
||||
from tests.conftest import BASE_URL, create_post_with_files, paste_image
|
||||
import time
|
||||
import requests
|
||||
from playwright.sync_api import expect
|
||||
@ -720,6 +720,28 @@ def test_paste_image_attaches_in_post_composer(alice):
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_feed_card_shows_the_post_image(alice):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
page, _ = alice
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (600, 400), (28, 120, 200)).save(buf, "PNG")
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Feed card image rendering check",
|
||||
[{"name": "card.png", "mimeType": "image/png", "buffer": buf.getvalue()}],
|
||||
)
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
card = page.locator(
|
||||
".post-card:has-text('Feed card image rendering check')"
|
||||
).first
|
||||
card.wait_for(state="visible", timeout=10000)
|
||||
image = card.locator(".attachment-gallery.single .gallery-thumb").first
|
||||
image.wait_for(state="visible", timeout=10000)
|
||||
assert "_thumb" not in image.get_attribute("src")
|
||||
|
||||
|
||||
def test_create_post_cancel_modal(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
|
||||
@ -2,7 +2,12 @@
|
||||
|
||||
import re
|
||||
from playwright.sync_api import expect
|
||||
from tests.conftest import BASE_URL, assert_share_copies, paste_image
|
||||
from tests.conftest import (
|
||||
BASE_URL,
|
||||
assert_share_copies,
|
||||
create_post_with_files,
|
||||
paste_image,
|
||||
)
|
||||
def create_post(page, topic="random", content="Test post content", title=None):
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
|
||||
@ -13,6 +18,27 @@ def create_post(page, topic="random", content="Test post content", title=None):
|
||||
page.fill("#post-title", title)
|
||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
||||
def _png_bytes(color=(0, 128, 255), size=(600, 400)):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", size, color).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _gif_bytes():
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
frames = [Image.new("RGB", (40, 40), c) for c in ((255, 0, 0), (0, 0, 255))]
|
||||
buf = io.BytesIO()
|
||||
frames[0].save(
|
||||
buf, "GIF", save_all=True, append_images=frames[1:], duration=120, loop=0
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _profile_stars(page, username):
|
||||
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
||||
value = page.locator(
|
||||
@ -390,6 +416,57 @@ def test_paste_image_attaches_in_comment_form(alice):
|
||||
).to_have_value(re.compile(r".+"))
|
||||
|
||||
|
||||
def test_single_image_post_shows_the_original_full_size(alice):
|
||||
page, _ = alice
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Post carrying exactly one image",
|
||||
[{"name": "shot.png", "mimeType": "image/png", "buffer": _png_bytes()}],
|
||||
)
|
||||
gallery = page.locator(".attachment-gallery.single")
|
||||
gallery.wait_for(state="visible", timeout=10000)
|
||||
img = gallery.locator(".gallery-thumb").first
|
||||
src = img.get_attribute("src")
|
||||
assert "_thumb" not in src, f"hero image served the 200px thumbnail: {src}"
|
||||
assert src == img.get_attribute("data-full")
|
||||
|
||||
|
||||
def test_multiple_image_post_keeps_thumbnails(alice):
|
||||
page, _ = alice
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Post carrying two images",
|
||||
[
|
||||
{"name": "one.png", "mimeType": "image/png", "buffer": _png_bytes()},
|
||||
{
|
||||
"name": "two.png",
|
||||
"mimeType": "image/png",
|
||||
"buffer": _png_bytes((200, 30, 90)),
|
||||
},
|
||||
],
|
||||
expected_count=2,
|
||||
)
|
||||
page.locator(".attachment-gallery").first.wait_for(state="visible", timeout=10000)
|
||||
assert page.locator(".attachment-gallery.single").count() == 0
|
||||
thumbs = page.locator(".attachment-gallery .gallery-thumb")
|
||||
assert thumbs.count() == 2
|
||||
for i in range(thumbs.count()):
|
||||
assert "_thumb" in thumbs.nth(i).get_attribute("src")
|
||||
|
||||
|
||||
def test_animated_gif_post_serves_the_original_file(alice):
|
||||
page, _ = alice
|
||||
create_post_with_files(
|
||||
page,
|
||||
"Post carrying an animated gif",
|
||||
[{"name": "loop.gif", "mimeType": "image/gif", "buffer": _gif_bytes()}],
|
||||
)
|
||||
img = page.locator(".attachment-gallery .gallery-thumb").first
|
||||
img.wait_for(state="visible", timeout=10000)
|
||||
src = img.get_attribute("src")
|
||||
assert src.endswith(".gif"), f"animation lost, served {src}"
|
||||
|
||||
|
||||
def test_attachment_upload_ui(alice):
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
@ -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")
|
||||
|
||||
0
tests/unit/services/opinionwar/__init__.py
Normal file
0
tests/unit/services/opinionwar/__init__.py
Normal file
94
tests/unit/services/opinionwar/rules.py
Normal file
94
tests/unit/services/opinionwar/rules.py
Normal 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) == ""
|
||||
Loading…
Reference in New Issue
Block a user