Compare commits
No commits in common. "master" and "PasteImage" have entirely different histories.
master
...
PasteImage
@ -155,7 +155,6 @@ 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/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/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/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/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/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 |
|
| `devplacepy/database/CLAUDE.md` | Dataset rules, indexing conventions, soft delete, tables, site settings |
|
||||||
@ -202,7 +201,6 @@ Routers in `devplacepy/routers/` are organised as a **directory tree that mirror
|
|||||||
| `/game` | game/ package - see `services/game/CLAUDE.md` |
|
| `/game` | game/ package - see `services/game/CLAUDE.md` |
|
||||||
| `/reports`, `/admin/moderation`, `/workspaces` | reports.py, admin/moderation.py, workspaces.py - see `services/moderation/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` |
|
| `/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`) |
|
| (none) | push.py (web push/PWA), docs.py (see `routers/docs/CLAUDE.md`) |
|
||||||
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
|
| `/pubsub` | pubsub.py - see `services/pubsub/CLAUDE.md` |
|
||||||
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
| (none) | seo.py - `/robots.txt`, `/sitemap.xml` |
|
||||||
|
|||||||
20
README.md
20
README.md
@ -97,7 +97,6 @@ devplacepy/
|
|||||||
| `/leaderboard` | Contributor ranking by total stars earned |
|
| `/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 |
|
| `/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 |
|
| `/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 |
|
| `/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) |
|
| `/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) |
|
| `/admin/services` | Background service management (start/stop, config, status, logs) |
|
||||||
@ -170,25 +169,6 @@ 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
|
Retention: completed attempts are permanent; abandoned and expired ones are garbage-collected by
|
||||||
`devplace quiz prune`.
|
`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
|
## 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.
|
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,8 +97,6 @@ QUIZ_SCOREBOARD_LIMIT = 20
|
|||||||
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
|
QUIZ_SCOREBOARD_CACHE_SECONDS = 15
|
||||||
QUIZ_LIST_PER_PAGE = 20
|
QUIZ_LIST_PER_PAGE = 20
|
||||||
|
|
||||||
BATTLES_LIST_PER_PAGE = 10
|
|
||||||
|
|
||||||
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
|
DEFAULT_CORRECTION_PROMPT = "Leave literary as is, only do punctuation and casing"
|
||||||
DEFAULT_MODIFIER_PROMPT = (
|
DEFAULT_MODIFIER_PROMPT = (
|
||||||
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
|
"Execute what is behind `@ai` (the prompt) and replace that part including `@ai`"
|
||||||
|
|||||||
@ -53,7 +53,6 @@ from devplacepy.utils import (
|
|||||||
XP_UPVOTE,
|
XP_UPVOTE,
|
||||||
)
|
)
|
||||||
from devplacepy.services.audit import record as audit
|
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.correction import schedule_correction
|
||||||
from devplacepy.services.ai_modifier import schedule_modification
|
from devplacepy.services.ai_modifier import schedule_modification
|
||||||
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
|
from devplacepy.services.seo_meta import schedule_seo_meta_for_table
|
||||||
@ -628,7 +627,6 @@ def detail_context(
|
|||||||
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
||||||
"bookmarked": detail.get("bookmarked", False),
|
"bookmarked": detail.get("bookmarked", False),
|
||||||
"poll": detail.get("poll"),
|
"poll": detail.get("poll"),
|
||||||
"war": detail.get("war"),
|
|
||||||
"project_link": detail.get("project_link"),
|
"project_link": detail.get("project_link"),
|
||||||
"maturity": detail.get("maturity", "general"),
|
"maturity": detail.get("maturity", "general"),
|
||||||
}
|
}
|
||||||
@ -819,9 +817,6 @@ def load_detail(
|
|||||||
"reactions": reactions,
|
"reactions": reactions,
|
||||||
"bookmarked": bookmarked,
|
"bookmarked": bookmarked,
|
||||||
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
"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,
|
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
|
||||||
"maturity": get_maturity(target_type, item["uid"])["level"],
|
"maturity": get_maturity(target_type, item["uid"])["level"],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,11 +89,6 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
|
|||||||
if not poll:
|
if not poll:
|
||||||
return "/feed"
|
return "/feed"
|
||||||
return resolve_object_url("post", poll.get("post_uid", ""))
|
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":
|
if target_type == "workspace":
|
||||||
instance = get_table("instances").find_one(uid=target_uid)
|
instance = get_table("instances").find_one(uid=target_uid)
|
||||||
return f"/admin/containers/{instance['uid']}" if instance else "/admin/containers"
|
return f"/admin/containers/{instance['uid']}" if instance else "/admin/containers"
|
||||||
|
|||||||
@ -17,7 +17,6 @@ REPORTABLE_TARGETS: dict[str, str] = {
|
|||||||
"message": "messages",
|
"message": "messages",
|
||||||
"quiz": "quizzes",
|
"quiz": "quizzes",
|
||||||
"poll": "polls",
|
"poll": "polls",
|
||||||
"battle": "opinion_wars",
|
|
||||||
"award": "awards",
|
"award": "awards",
|
||||||
"user": "users",
|
"user": "users",
|
||||||
"issue": "issue_tickets",
|
"issue": "issue_tickets",
|
||||||
@ -49,8 +48,6 @@ UNREPORTABLE_TABLES: dict[str, str] = {
|
|||||||
"bookmarks": "private to the owner",
|
"bookmarks": "private to the owner",
|
||||||
"follows": "relationship rows, carry no authored content",
|
"follows": "relationship rows, carry no authored content",
|
||||||
"poll_votes": "private ballots",
|
"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_attempts": "private to the participant",
|
||||||
"quiz_answers": "private to the participant",
|
"quiz_answers": "private to the participant",
|
||||||
"sessions": "authentication state",
|
"sessions": "authentication state",
|
||||||
|
|||||||
@ -19,7 +19,6 @@ NOTIFICATION_TYPES = [
|
|||||||
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
|
{"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": "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": "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": "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": "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)"},
|
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
|
||||||
|
|||||||
@ -150,16 +150,6 @@ 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_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"])
|
||||||
soft_delete("poll_options", 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)
|
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:
|
def delete_engagement(target_type: str, target_uids: list) -> None:
|
||||||
@ -191,14 +181,6 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
|
|||||||
if "poll_options" in tables:
|
if "poll_options" in tables:
|
||||||
db["poll_options"].delete(poll_uid=poll["uid"])
|
db["poll_options"].delete(poll_uid=poll["uid"])
|
||||||
db["polls"].delete(post_uid=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:
|
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
||||||
|
|||||||
@ -1580,98 +1580,6 @@ def init_db():
|
|||||||
)
|
)
|
||||||
_index(db, "quiz_answers", "idx_quiz_answers_quiz", ["quiz_uid"])
|
_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, "posts", "idx_posts_user_created", ["user_uid", "created_at"])
|
||||||
_index(
|
_index(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@ -51,9 +51,6 @@ SOFT_DELETE_TABLES = [
|
|||||||
"quiz_options",
|
"quiz_options",
|
||||||
"quiz_attempts",
|
"quiz_attempts",
|
||||||
"quiz_answers",
|
"quiz_answers",
|
||||||
"opinion_wars",
|
|
||||||
"opinion_war_fighters",
|
|
||||||
"opinion_war_events",
|
|
||||||
"content_reports",
|
"content_reports",
|
||||||
"moderation_actions",
|
"moderation_actions",
|
||||||
"content_maturity",
|
"content_maturity",
|
||||||
|
|||||||
@ -22,7 +22,6 @@ from . import (
|
|||||||
admin,
|
admin,
|
||||||
game,
|
game,
|
||||||
quizzes,
|
quizzes,
|
||||||
battles,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ORDERED_GROUPS = [
|
ORDERED_GROUPS = [
|
||||||
@ -47,5 +46,4 @@ ORDERED_GROUPS = [
|
|||||||
admin.GROUP,
|
admin.GROUP,
|
||||||
game.GROUP,
|
game.GROUP,
|
||||||
quizzes.GROUP,
|
quizzes.GROUP,
|
||||||
battles.GROUP,
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
# 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,22 +117,6 @@ four ways to sign requests.
|
|||||||
"",
|
"",
|
||||||
"Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
|
"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."],
|
notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -49,7 +49,6 @@ 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.seo import base_seo_context, site_url, website_schema
|
||||||
from devplacepy.routers import (
|
from devplacepy.routers import (
|
||||||
auth,
|
auth,
|
||||||
battles,
|
|
||||||
feed,
|
feed,
|
||||||
posts,
|
posts,
|
||||||
comments,
|
comments,
|
||||||
@ -107,7 +106,6 @@ from devplacepy.services.backup import BackupService
|
|||||||
from devplacepy.services.dbapi.service import DbApiJobService
|
from devplacepy.services.dbapi.service import DbApiJobService
|
||||||
from devplacepy.services.pubsub import PubSubService
|
from devplacepy.services.pubsub import PubSubService
|
||||||
from devplacepy.services.notification_relay import NotificationRelayService
|
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.live_view_relay import LiveViewRelayService
|
||||||
from devplacepy.services.presence_relay import PresenceRelayService
|
from devplacepy.services.presence_relay import PresenceRelayService
|
||||||
from devplacepy.services import presence
|
from devplacepy.services import presence
|
||||||
@ -274,7 +272,6 @@ async def lifespan(app: FastAPI):
|
|||||||
service_manager.register(DbApiJobService())
|
service_manager.register(DbApiJobService())
|
||||||
service_manager.register(PubSubService())
|
service_manager.register(PubSubService())
|
||||||
service_manager.register(NotificationRelayService())
|
service_manager.register(NotificationRelayService())
|
||||||
service_manager.register(OpinionWarService())
|
|
||||||
service_manager.register(LiveViewRelayService())
|
service_manager.register(LiveViewRelayService())
|
||||||
service_manager.register(PresenceRelayService())
|
service_manager.register(PresenceRelayService())
|
||||||
service_manager.register(DeepsearchService())
|
service_manager.register(DeepsearchService())
|
||||||
@ -507,7 +504,6 @@ app.include_router(dbapi.router, prefix="/dbapi")
|
|||||||
app.include_router(pubsub.router, prefix="/pubsub")
|
app.include_router(pubsub.router, prefix="/pubsub")
|
||||||
app.include_router(game.router, prefix="/game")
|
app.include_router(game.router, prefix="/game")
|
||||||
app.include_router(quizzes.router, prefix="/quizzes")
|
app.include_router(quizzes.router, prefix="/quizzes")
|
||||||
app.include_router(battles.router, prefix="/battles")
|
|
||||||
app.include_router(workspaces.router, prefix="/workspaces")
|
app.include_router(workspaces.router, prefix="/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -176,8 +176,6 @@ class PostForm(BaseModel):
|
|||||||
attachment_uids: list[str] = []
|
attachment_uids: list[str] = []
|
||||||
poll_question: str = Field(default="", max_length=200)
|
poll_question: str = Field(default="", max_length=200)
|
||||||
poll_options: list[str] = []
|
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")
|
@field_validator("poll_options")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -200,17 +198,6 @@ class PostForm(BaseModel):
|
|||||||
return normalize_poll_options(value)
|
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):
|
class PostEditForm(BaseModel):
|
||||||
content: str = Field(min_length=10, max_length=125000)
|
content: str = Field(min_length=10, max_length=125000)
|
||||||
title: str = Field(default="", max_length=500)
|
title: str = Field(default="", max_length=500)
|
||||||
|
|||||||
@ -46,7 +46,6 @@ 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 |
|
| `/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` |
|
| `/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` |
|
| `/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` |
|
| `/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) | 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) |
|
| (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) |
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
# 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,9 +189,7 @@ async def clippy_proxy(request: Request):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-App-Reference": "devplace-devii-v-1-0-0",
|
"X-App-Reference": "devplace-devii-v-1-0-0",
|
||||||
}
|
}
|
||||||
if user.get("api_key"):
|
if cfg.get("devii_ai_key"):
|
||||||
headers["Authorization"] = f"Bearer {user['api_key']}"
|
|
||||||
elif cfg.get("devii_ai_key"):
|
|
||||||
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
|
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
|
||||||
async with stealth.stealth_async_client(timeout=45.0) as client:
|
async with stealth.stealth_async_client(timeout=45.0) as client:
|
||||||
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)
|
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)
|
||||||
|
|||||||
@ -82,12 +82,6 @@ DOCS_PAGES = [
|
|||||||
"kind": "prose",
|
"kind": "prose",
|
||||||
"section": SECTION_GENERAL,
|
"section": SECTION_GENERAL,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"slug": "opinion-wars",
|
|
||||||
"title": "Opinion Wars",
|
|
||||||
"kind": "prose",
|
|
||||||
"section": SECTION_GENERAL,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"slug": "block-and-mute",
|
"slug": "block-and-mute",
|
||||||
"title": "Block and mute",
|
"title": "Block and mute",
|
||||||
|
|||||||
@ -14,9 +14,6 @@ from devplacepy.database import (
|
|||||||
get_reactions_by_targets,
|
get_reactions_by_targets,
|
||||||
get_user_bookmarks,
|
get_user_bookmarks,
|
||||||
get_polls_by_post_uids,
|
get_polls_by_post_uids,
|
||||||
)
|
|
||||||
from devplacepy.services.opinionwar import store as war_store
|
|
||||||
from devplacepy.database import (
|
|
||||||
paginate_diverse,
|
paginate_diverse,
|
||||||
text_search_clause,
|
text_search_clause,
|
||||||
)
|
)
|
||||||
@ -104,7 +101,6 @@ async def feed_page(
|
|||||||
get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
|
get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
|
||||||
)
|
)
|
||||||
polls_map = get_polls_by_post_uids(post_uids_list, user)
|
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:
|
for item in posts:
|
||||||
uid = item["post"]["uid"]
|
uid = item["post"]["uid"]
|
||||||
item["attachments"] = attachments_map.get(uid, [])
|
item["attachments"] = attachments_map.get(uid, [])
|
||||||
@ -112,7 +108,6 @@ async def feed_page(
|
|||||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||||
item["bookmarked"] = uid in bookmark_set
|
item["bookmarked"] = uid in bookmark_set
|
||||||
item["poll"] = polls_map.get(uid)
|
item["poll"] = polls_map.get(uid)
|
||||||
item["war"] = wars_map.get(uid)
|
|
||||||
|
|
||||||
seo_ctx = list_page_seo(
|
seo_ctx = list_page_seo(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@ -41,7 +41,6 @@ from devplacepy.seo import (
|
|||||||
from devplacepy.attachments import save_inline_image
|
from devplacepy.attachments import save_inline_image
|
||||||
from devplacepy.models import PostForm, PostEditForm
|
from devplacepy.models import PostForm, PostEditForm
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
from devplacepy.services.opinionwar import store as war_store
|
|
||||||
from devplacepy.dependencies import json_or_form
|
from devplacepy.dependencies import json_or_form
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -88,7 +87,6 @@ async def create_post(request: Request, data: Annotated[PostForm, Depends(json_o
|
|||||||
)
|
)
|
||||||
|
|
||||||
create_poll(uid, user, data.poll_question, data.poll_options, request)
|
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}"
|
url = f"/posts/{post_slug}"
|
||||||
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
|
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
|
||||||
|
|
||||||
|
|||||||
@ -17,9 +17,6 @@ from devplacepy.database import (
|
|||||||
get_reactions_by_targets,
|
get_reactions_by_targets,
|
||||||
get_user_bookmarks,
|
get_user_bookmarks,
|
||||||
get_polls_by_post_uids,
|
get_polls_by_post_uids,
|
||||||
)
|
|
||||||
from devplacepy.services.opinionwar import store as war_store
|
|
||||||
from devplacepy.database import (
|
|
||||||
get_activity_heatmap,
|
get_activity_heatmap,
|
||||||
get_activity_months,
|
get_activity_months,
|
||||||
get_streaks,
|
get_streaks,
|
||||||
@ -206,13 +203,11 @@ async def profile_page(
|
|||||||
else set()
|
else set()
|
||||||
)
|
)
|
||||||
polls_map = get_polls_by_post_uids(post_uids, current_user)
|
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:
|
for item in posts:
|
||||||
uid = item["post"]["uid"]
|
uid = item["post"]["uid"]
|
||||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||||
item["bookmarked"] = uid in bookmark_set
|
item["bookmarked"] = uid in bookmark_set
|
||||||
item["poll"] = polls_map.get(uid)
|
item["poll"] = polls_map.get(uid)
|
||||||
item["war"] = wars_map.get(uid)
|
|
||||||
|
|
||||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||||
for b in badges:
|
for b in badges:
|
||||||
|
|||||||
@ -23,9 +23,6 @@ from devplacepy.database import (
|
|||||||
get_reactions_by_targets,
|
get_reactions_by_targets,
|
||||||
get_user_bookmarks,
|
get_user_bookmarks,
|
||||||
get_polls_by_post_uids,
|
get_polls_by_post_uids,
|
||||||
)
|
|
||||||
from devplacepy.services.opinionwar import store as war_store
|
|
||||||
from devplacepy.database import (
|
|
||||||
paginate,
|
paginate,
|
||||||
text_search_clause,
|
text_search_clause,
|
||||||
resolve_by_slug,
|
resolve_by_slug,
|
||||||
@ -292,14 +289,12 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
|||||||
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
|
||||||
)
|
)
|
||||||
polls_map = get_polls_by_post_uids(post_uids, user)
|
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:
|
for item in devlog_posts:
|
||||||
uid = item["post"]["uid"]
|
uid = item["post"]["uid"]
|
||||||
item["attachments"] = attachments_map.get(uid, [])
|
item["attachments"] = attachments_map.get(uid, [])
|
||||||
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
|
||||||
item["bookmarked"] = uid in bookmark_set
|
item["bookmarked"] = uid in bookmark_set
|
||||||
item["poll"] = polls_map.get(uid)
|
item["poll"] = polls_map.get(uid)
|
||||||
item["war"] = wars_map.get(uid)
|
|
||||||
|
|
||||||
return respond(
|
return respond(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@ -151,15 +151,6 @@ from devplacepy.schemas.dbapi import (
|
|||||||
DbTableOut,
|
DbTableOut,
|
||||||
NlQueryOut,
|
NlQueryOut,
|
||||||
)
|
)
|
||||||
from devplacepy.schemas.battles import (
|
|
||||||
BattlesOut,
|
|
||||||
WarContributorOut,
|
|
||||||
WarEventOut,
|
|
||||||
WarEventsOut,
|
|
||||||
WarOut,
|
|
||||||
WarPersonOut,
|
|
||||||
WarViewerOut,
|
|
||||||
)
|
|
||||||
from devplacepy.schemas.quiz import (
|
from devplacepy.schemas.quiz import (
|
||||||
QuizAnswerOut,
|
QuizAnswerOut,
|
||||||
QuizAnswerResultOut,
|
QuizAnswerResultOut,
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
# 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,7 +5,6 @@ from __future__ import annotations
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from devplacepy.schemas.base import _Out
|
from devplacepy.schemas.base import _Out
|
||||||
from devplacepy.schemas.battles import WarOut
|
|
||||||
from devplacepy.schemas.content import (
|
from devplacepy.schemas.content import (
|
||||||
AttachmentOut,
|
AttachmentOut,
|
||||||
CommentItemOut,
|
CommentItemOut,
|
||||||
@ -34,7 +33,6 @@ class FeedItemOut(_Out):
|
|||||||
reactions: ReactionsOut = ReactionsOut()
|
reactions: ReactionsOut = ReactionsOut()
|
||||||
bookmarked: bool = False
|
bookmarked: bool = False
|
||||||
poll: Optional[PollOut] = None
|
poll: Optional[PollOut] = None
|
||||||
war: Optional[WarOut] = None
|
|
||||||
project_link: Optional[ProjectLinkOut] = None
|
project_link: Optional[ProjectLinkOut] = None
|
||||||
|
|
||||||
|
|
||||||
@ -136,7 +134,6 @@ class PostDetailOut(_Out):
|
|||||||
reactions: ReactionsOut = ReactionsOut()
|
reactions: ReactionsOut = ReactionsOut()
|
||||||
bookmarked: bool = False
|
bookmarked: bool = False
|
||||||
poll: Optional[PollOut] = None
|
poll: Optional[PollOut] = None
|
||||||
war: Optional[WarOut] = None
|
|
||||||
comment_count: Optional[int] = None
|
comment_count: Optional[int] = None
|
||||||
related_posts: list[FeedItemOut] = []
|
related_posts: list[FeedItemOut] = []
|
||||||
topics: list[str] = []
|
topics: list[str] = []
|
||||||
|
|||||||
@ -426,7 +426,6 @@ 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}/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}/quizzes", changefreq="daily", priority="0.8"))
|
||||||
urlset.append(url_element(f"{base_url}/battles", changefreq="daily", priority="0.7"))
|
|
||||||
urlset.append(
|
urlset.append(
|
||||||
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
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/`)
|
## 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 293 keys across 43 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 288 keys across 42 domains.
|
||||||
|
|
||||||
### Package layout
|
### Package layout
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,6 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
|||||||
"reaction": "engagement",
|
"reaction": "engagement",
|
||||||
"bookmark": "engagement",
|
"bookmark": "engagement",
|
||||||
"poll": "engagement",
|
"poll": "engagement",
|
||||||
"battle": "engagement",
|
|
||||||
"project": "project",
|
"project": "project",
|
||||||
"file": "project_files",
|
"file": "project_files",
|
||||||
"dir": "project_files",
|
"dir": "project_files",
|
||||||
|
|||||||
@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
from ..spec import Action, Catalog
|
from ..spec import Action, Catalog
|
||||||
from .admin import ADMIN_ACTIONS
|
from .admin import ADMIN_ACTIONS
|
||||||
from .auth import AUTH_ACTIONS
|
from .auth import AUTH_ACTIONS
|
||||||
from .battles import BATTLE_ACTIONS
|
|
||||||
from .comments import COMMENTS_ACTIONS
|
from .comments import COMMENTS_ACTIONS
|
||||||
from .dbapi import DBAPI_ACTIONS
|
from .dbapi import DBAPI_ACTIONS
|
||||||
from .engagement import ENGAGEMENT_ACTIONS
|
from .engagement import ENGAGEMENT_ACTIONS
|
||||||
@ -49,7 +48,6 @@ ACTIONS: tuple[Action, ...] = (
|
|||||||
+ GATEWAY_ACTIONS
|
+ GATEWAY_ACTIONS
|
||||||
+ GAME_ACTIONS
|
+ GAME_ACTIONS
|
||||||
+ QUIZ_ACTIONS
|
+ QUIZ_ACTIONS
|
||||||
+ BATTLE_ACTIONS
|
|
||||||
+ MODERATION_ACTIONS
|
+ MODERATION_ACTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
# 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,14 +36,6 @@ POSTS_ACTIONS: tuple[Action, ...] = (
|
|||||||
"poll_options",
|
"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.",
|
"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(
|
Action(
|
||||||
|
|||||||
@ -32,8 +32,6 @@ from .spec import Action, Catalog
|
|||||||
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
||||||
|
|
||||||
CONFIRM_REQUIRED = {
|
CONFIRM_REQUIRED = {
|
||||||
"join_battle",
|
|
||||||
"fight_battle",
|
|
||||||
"delete_my_account",
|
"delete_my_account",
|
||||||
"decide_report",
|
"decide_report",
|
||||||
"suspend_user",
|
"suspend_user",
|
||||||
|
|||||||
@ -1,134 +0,0 @@
|
|||||||
# 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/`.
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
|
|
||||||
from . import rules, store
|
|
||||||
from .store import WarError
|
|
||||||
|
|
||||||
__all__ = ["rules", "store", "WarError"]
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
# 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}
|
|
||||||
@ -1,794 +0,0 @@
|
|||||||
# 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,23 +29,6 @@
|
|||||||
max-height: 200px;
|
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) {
|
.attachment-gallery-item:has(.non-image) {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
|
|||||||
@ -1,714 +0,0 @@
|
|||||||
: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,20 +681,6 @@
|
|||||||
margin-bottom: var(--space-xl);
|
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 {
|
.quiz-question-answered .quiz-answer-form {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import { CounterManager } from "./CounterManager.js";
|
|||||||
import { ReactionBar } from "./ReactionBar.js";
|
import { ReactionBar } from "./ReactionBar.js";
|
||||||
import { BookmarkManager } from "./BookmarkManager.js";
|
import { BookmarkManager } from "./BookmarkManager.js";
|
||||||
import { PollManager } from "./PollManager.js";
|
import { PollManager } from "./PollManager.js";
|
||||||
import { WarComposer } from "./WarComposer.js";
|
|
||||||
import { ApiKeyManager } from "./ApiKeyManager.js";
|
import { ApiKeyManager } from "./ApiKeyManager.js";
|
||||||
import { AvatarRegenerator } from "./AvatarRegenerator.js";
|
import { AvatarRegenerator } from "./AvatarRegenerator.js";
|
||||||
import { CustomizationToggle } from "./CustomizationToggle.js";
|
import { CustomizationToggle } from "./CustomizationToggle.js";
|
||||||
@ -88,7 +87,6 @@ class Application {
|
|||||||
this.reactions = new ReactionBar();
|
this.reactions = new ReactionBar();
|
||||||
this.bookmarks = new BookmarkManager();
|
this.bookmarks = new BookmarkManager();
|
||||||
this.polls = new PollManager();
|
this.polls = new PollManager();
|
||||||
this.warComposer = new WarComposer();
|
|
||||||
this.apiKey = new ApiKeyManager();
|
this.apiKey = new ApiKeyManager();
|
||||||
this.avatarRegenerator = new AvatarRegenerator();
|
this.avatarRegenerator = new AvatarRegenerator();
|
||||||
this.customizationToggle = new CustomizationToggle();
|
this.customizationToggle = new CustomizationToggle();
|
||||||
|
|||||||
@ -92,11 +92,7 @@ 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.
|
**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.
|
`_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.
|
||||||
|
|
||||||
**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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -1,106 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,254 +0,0 @@
|
|||||||
// 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,9 +16,7 @@ export class AppQuizPlayer extends Component {
|
|||||||
this._progress = this.querySelector("[data-quiz-progress]");
|
this._progress = this.querySelector("[data-quiz-progress]");
|
||||||
this._score = this.querySelector("[data-quiz-score]");
|
this._score = this.querySelector("[data-quiz-score]");
|
||||||
this._timer = this.querySelector("[data-quiz-timer]");
|
this._timer = this.querySelector("[data-quiz-timer]");
|
||||||
this._slides = Array.from(this.querySelectorAll("[data-quiz-slide]"));
|
|
||||||
this._bindForms();
|
this._bindForms();
|
||||||
if (this._slides.length) this._initSlides();
|
|
||||||
if (this._hasLimit && this._status === "in_progress") this._startTimer();
|
if (this._hasLimit && this._status === "in_progress") this._startTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,46 +24,6 @@ export class AppQuizPlayer extends Component {
|
|||||||
if (this._interval) clearInterval(this._interval);
|
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() {
|
_bindForms() {
|
||||||
this.querySelectorAll("[data-quiz-answer-form]").forEach((form) => {
|
this.querySelectorAll("[data-quiz-answer-form]").forEach((form) => {
|
||||||
form.addEventListener("submit", (event) => {
|
form.addEventListener("submit", (event) => {
|
||||||
@ -175,9 +133,9 @@ export class AppQuizPlayer extends Component {
|
|||||||
|
|
||||||
_advance(form) {
|
_advance(form) {
|
||||||
const question = form.closest("[data-quiz-question]");
|
const question = form.closest("[data-quiz-question]");
|
||||||
if (!question || !this._slides.length) return;
|
if (!question) return;
|
||||||
const index = this._slides.indexOf(question);
|
const next = question.nextElementSibling;
|
||||||
if (index !== -1 && index < this._slides.length - 1) this._showSlide(index + 1);
|
if (next && next.scrollIntoView) next.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,5 +15,4 @@ import "./AppIsslopRun.js";
|
|||||||
import "./AppChat.js";
|
import "./AppChat.js";
|
||||||
import "./AppQuizPlayer.js";
|
import "./AppQuizPlayer.js";
|
||||||
import "./AppQuizBuilder.js";
|
import "./AppQuizBuilder.js";
|
||||||
import "./AppOpinionWar.js";
|
|
||||||
import "./ContainerTerminal.js";
|
import "./ContainerTerminal.js";
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
{% set _single = attachments|length == 1 %}
|
<div class="attachment-gallery">
|
||||||
<div class="attachment-gallery{% if _single %} single{% endif %}">
|
|
||||||
{% for att in attachments %}
|
{% for att in attachments %}
|
||||||
<div class="attachment-gallery-item">
|
<div class="attachment-gallery-item">
|
||||||
{% if att.get('is_image') %}
|
{% if att.get('is_image') and att.get('thumbnail_url') %}
|
||||||
<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', '') }}">
|
<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', '') }}">
|
||||||
{% elif att.get('is_video') %}
|
{% elif att.get('is_video') %}
|
||||||
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||||
{% elif att.get('is_audio') %}
|
{% elif att.get('is_audio') %}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
{% 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,8 +25,7 @@
|
|||||||
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
|
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% set attachments = item.get('attachments', []) %}
|
{% if item.attachments %}
|
||||||
{% if attachments %}
|
|
||||||
{% include "_attachment_display.html" %}
|
{% include "_attachment_display.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -34,10 +33,6 @@
|
|||||||
{% set _poll = item.poll %}{% include "_poll.html" %}
|
{% set _poll = item.poll %}{% include "_poll.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if item.war %}
|
|
||||||
{% set _war = item.war %}{% include "_opinion_war.html" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="post-actions">
|
<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" %}
|
{% 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">
|
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn" aria-label="{{ item.comment_count }} comments">
|
||||||
|
|||||||
@ -41,19 +41,6 @@
|
|||||||
<p class="poll-builder-error" data-poll-error hidden></p>
|
<p class="poll-builder-error" data-poll-error hidden></p>
|
||||||
</div>
|
</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">
|
<div class="modal-footer">
|
||||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Post</button>
|
<button type="submit" class="btn btn-primary">Post</button>
|
||||||
|
|||||||
@ -52,7 +52,6 @@
|
|||||||
<link rel="stylesheet" href="{{ static_url('/static/css/lightbox.css') }}">
|
<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/media.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static_url('/static/css/engagement.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 %}
|
{% block extra_head %}{% endblock %}
|
||||||
{{ custom_css_tag(request) }}
|
{{ custom_css_tag(request) }}
|
||||||
{{ extra_head_tag() }}
|
{{ extra_head_tag() }}
|
||||||
@ -75,7 +74,6 @@
|
|||||||
<a href="/gists" class="topnav-link {{ nav_active(request, '/gists') }}"><span class="icon">đź“„</span> Gists</a>
|
<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="/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="/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 %}
|
{% if user %}<a href="/game" class="topnav-link {{ nav_active(request, '/game') }}"><span class="icon">🌱</span> Farm</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="topnav-right">
|
<div class="topnav-right">
|
||||||
@ -146,7 +144,6 @@
|
|||||||
<a href="/gists" class="topnav-mobile-link {{ nav_active(request, '/gists') }}"><span class="icon">đź“„</span> Gists</a>
|
<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="/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="/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>
|
<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 %}
|
{% 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>
|
<div class="topnav-mobile-divider"></div>
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<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,10 +44,6 @@
|
|||||||
{% set _poll = poll %}{% include "_poll.html" %}
|
{% set _poll = poll %}{% include "_poll.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if war %}
|
|
||||||
{% set _war = war %}{% include "_opinion_war.html" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="post-detail-actions">
|
<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 _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" %}
|
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
|
||||||
|
|||||||
@ -69,7 +69,7 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
{% if leaderboard %}
|
{% if leaderboard %}
|
||||||
<section id="leaderboard" class="quiz-leaderboard" aria-label="Quiz leaderboard">
|
<section class="quiz-leaderboard" aria-label="Quiz leaderboard">
|
||||||
<h2>Leaderboard</h2>
|
<h2>Leaderboard</h2>
|
||||||
<ol class="quiz-leaderboard-list">
|
<ol class="quiz-leaderboard-list">
|
||||||
{% for entry in leaderboard %}
|
{% for entry in leaderboard %}
|
||||||
|
|||||||
@ -46,7 +46,7 @@
|
|||||||
<form method="POST" action="{{ quiz.url }}/attempts" class="inline-form">
|
<form method="POST" action="{{ quiz.url }}/attempts" class="inline-form">
|
||||||
<button type="submit" class="btn btn-primary">Play again</button>
|
<button type="submit" class="btn btn-primary">Play again</button>
|
||||||
</form>
|
</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>
|
<a href="/quizzes" class="btn btn-secondary">All quizzes</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,7 +21,6 @@ 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` |
|
| `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`) |
|
| `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 |
|
| `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.
|
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,9 +117,6 @@ from devplacepy.utils.rewards import (
|
|||||||
XP_QUIZ,
|
XP_QUIZ,
|
||||||
XP_QUIZ_PUBLISH,
|
XP_QUIZ_PUBLISH,
|
||||||
XP_QUIZ_COMPLETE,
|
XP_QUIZ_COMPLETE,
|
||||||
XP_BATTLE_PART,
|
|
||||||
XP_BATTLE_WIN,
|
|
||||||
XP_BATTLE_TOP,
|
|
||||||
level_for_xp,
|
level_for_xp,
|
||||||
award_xp,
|
award_xp,
|
||||||
_COUNT_MILESTONES,
|
_COUNT_MILESTONES,
|
||||||
|
|||||||
@ -55,10 +55,6 @@ BADGE_CATALOG = {
|
|||||||
"Researcher": {"icon": "🔬", "description": "Ran a DeepSearch investigation", "group": "Explorer"},
|
"Researcher": {"icon": "🔬", "description": "Ran a DeepSearch investigation", "group": "Explorer"},
|
||||||
"Deep Diver": {"icon": "🌊", "description": "Ran 10 DeepSearch investigations", "group": "Explorer"},
|
"Deep Diver": {"icon": "🌊", "description": "Ran 10 DeepSearch investigations", "group": "Explorer"},
|
||||||
"Container Captain": {"icon": "📦", "description": "Created a container instance", "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"},
|
"Messenger": {"icon": "✉", "description": "Sent your first direct message", "group": "Community"},
|
||||||
"Chatterbox": {"icon": "📨", "description": "Sent 100 direct messages", "group": "Community"},
|
"Chatterbox": {"icon": "📨", "description": "Sent 100 direct messages", "group": "Community"},
|
||||||
"Bookmarker": {"icon": "đź”–", "description": "Bookmarked your first item", "group": "Engagement"},
|
"Bookmarker": {"icon": "đź”–", "description": "Bookmarked your first item", "group": "Engagement"},
|
||||||
|
|||||||
@ -22,9 +22,6 @@ XP_FOLLOW = 5
|
|||||||
XP_QUIZ = 8
|
XP_QUIZ = 8
|
||||||
XP_QUIZ_PUBLISH = 12
|
XP_QUIZ_PUBLISH = 12
|
||||||
XP_QUIZ_COMPLETE = 4
|
XP_QUIZ_COMPLETE = 4
|
||||||
XP_BATTLE_PART = 5
|
|
||||||
XP_BATTLE_WIN = 15
|
|
||||||
XP_BATTLE_TOP = 10
|
|
||||||
|
|
||||||
|
|
||||||
def level_for_xp(xp: int) -> int:
|
def level_for_xp(xp: int) -> int:
|
||||||
@ -178,9 +175,6 @@ ACHIEVEMENTS = {
|
|||||||
"docs.read": [(1, "Curious"), (5, "Studious"), (15, "Scholar")],
|
"docs.read": [(1, "Curious"), (5, "Studious"), (15, "Scholar")],
|
||||||
"devii": [(1, "AI Curious"), (25, "AI Whisperer")],
|
"devii": [(1, "AI Curious"), (25, "AI Whisperer")],
|
||||||
"fork": [(1, "First Fork"), (5, "Forker")],
|
"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")],
|
"zip": [(1, "Archivist")],
|
||||||
"seo": [(1, "SEO Auditor")],
|
"seo": [(1, "SEO Auditor")],
|
||||||
"isslop": [(1, "Slop Hunter")],
|
"isslop": [(1, "Slop Hunter")],
|
||||||
|
|||||||
@ -328,11 +328,6 @@ Every state-changing action in DevPlace records one append-only row through `dev
|
|||||||
| `poll.vote.cast` | `routers/polls.py` |
|
| `poll.vote.cast` | `routers/polls.py` |
|
||||||
| `poll.vote.change` | `routers/polls.py` |
|
| `poll.vote.change` | `routers/polls.py` |
|
||||||
| `poll.vote.clear` | `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.add` | `routers/reactions.py` |
|
||||||
| `reaction.remove` | `routers/reactions.py` |
|
| `reaction.remove` | `routers/reactions.py` |
|
||||||
| `vote.comment.clear` | `content.py` |
|
| `vote.comment.clear` | `content.py` |
|
||||||
|
|||||||
@ -1,78 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
# 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,109 +795,3 @@ def test_feed_includes_project_when_linked(app_server):
|
|||||||
assert "/projects/" in item["project_link"]["url"]
|
assert "/projects/" in item["project_link"]["url"]
|
||||||
break
|
break
|
||||||
assert found, "feed must include project info for linked posts"
|
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,23 +344,6 @@ 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):
|
def assert_share_copies(page, expected_fragment):
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
|||||||
@ -1,156 +0,0 @@
|
|||||||
# 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>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
from tests.conftest import BASE_URL, create_post_with_files, paste_image
|
from tests.conftest import BASE_URL, paste_image
|
||||||
import time
|
import time
|
||||||
import requests
|
import requests
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
@ -720,28 +720,6 @@ def test_paste_image_attaches_in_post_composer(alice):
|
|||||||
).to_have_value(re.compile(r".+"))
|
).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):
|
def test_create_post_cancel_modal(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
|
|||||||
@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
from tests.conftest import (
|
from tests.conftest import BASE_URL, assert_share_copies, paste_image
|
||||||
BASE_URL,
|
|
||||||
assert_share_copies,
|
|
||||||
create_post_with_files,
|
|
||||||
paste_image,
|
|
||||||
)
|
|
||||||
def create_post(page, topic="random", content="Test post content", title=None):
|
def create_post(page, topic="random", content="Test post content", title=None):
|
||||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
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.wait_for(state="visible", timeout=10000)
|
||||||
@ -18,27 +13,6 @@ def create_post(page, topic="random", content="Test post content", title=None):
|
|||||||
page.fill("#post-title", title)
|
page.fill("#post-title", title)
|
||||||
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
|
||||||
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
|
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):
|
def _profile_stars(page, username):
|
||||||
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
|
||||||
value = page.locator(
|
value = page.locator(
|
||||||
@ -416,57 +390,6 @@ def test_paste_image_attaches_in_comment_form(alice):
|
|||||||
).to_have_value(re.compile(r".+"))
|
).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):
|
def test_attachment_upload_ui(alice):
|
||||||
import io
|
import io
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|||||||
@ -68,33 +68,6 @@ def test_starting_opens_the_player(alice):
|
|||||||
page.locator(".quiz-hud").wait_for(state="visible")
|
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):
|
def test_the_player_renders_a_real_form_per_question(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
slug = _author_quiz("Player forms quiz")
|
slug = _author_quiz("Player forms quiz")
|
||||||
@ -156,7 +129,7 @@ def test_an_answered_question_cannot_be_resubmitted(alice):
|
|||||||
|
|
||||||
def test_a_numeric_question_takes_a_typed_answer(alice):
|
def test_a_numeric_question_takes_a_typed_answer(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
slug = _author_quiz("Player numeric quiz", questions=(NUMERIC,))
|
slug = _author_quiz("Player numeric quiz")
|
||||||
_start(page, slug)
|
_start(page, slug)
|
||||||
question = page.locator(".quiz-question-numeric").first
|
question = page.locator(".quiz-question-numeric").first
|
||||||
question.locator("input[name='answer_text']").fill("1024")
|
question.locator("input[name='answer_text']").fill("1024")
|
||||||
|
|||||||
@ -1,94 +0,0 @@
|
|||||||
# 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