forked from retoor/devplacepy
A new post attachment type beside polls: the composer gains a Start
Opinion War builder (same disabled-inputs opt-in as the poll builder)
that names exactly two factions; the battle runs for exactly 7 days
from post creation. Members join a side, may defect at any time
(damage already dealt stays with the faction it was dealt to), and
fight once per 24 hours per battle. A fight spends 25 Code Farm coins
and deals deterministic level-weighted damage: 100 + 10 * min(level,
20) HP, so a newcomer deals 110 and a veteran caps at 300 - no
randomness anywhere.
The battle renders on the post card as a CSS pixel-art battlefield
(box-shadow sprites: castles, faction flags, marching soldiers, a
flickering campfire; steps() animation, disabled under reduced motion)
with live HP bars, a countdown, the viewer's faction strip, top
contributors and an event ticker. Live frames ride pub/sub on
public.battle.{uid} via a relay on the service-lock owner, with the
durable opinion_war_events trail (per-war atomic seq) as the source of
truth and a 15s incremental poller as fallback. /battles lists battles
with active/ended/mine filters, search and pagination.
Every mutation is a conditional UPDATE via conditional_update_row: the
fight sequence claims the cooldown first, then spends coins, then lands
the damage, compensating earlier steps on any later refusal so a crash
costs a turn, never coins. Resolution is lazy on read (no cron):
an exactly-once CAS computes the winner in the statement, awards XP
(participation, winner bonus, top damage dealer bonus; draws pay
participation only), emits the result event and notifies fighters. The
OpinionWarService backstop resolves unviewed wars and sends
fight-ready notifications, exactly-once via a marker CAS.
Fan-out: battle notification type, four badges, audit keys
(battle.create/join/switch/fight/resolve), Devii actions (join/fight
confirm-gated), API docs group, docs prose page, sitemap and topnav
entries, REPORTABLE_TARGETS registration, post-delete cascades,
README and nested CLAUDE.md documentation.
Verified with the four-layer procedure: property checks over the full
damage domain, 1200-step stateful fuzz (hp-sum invariant, coins never
negative, resolved totals frozen), and real 8-process races proving
exactly-once semantics for concurrent fights, double-spends across two
wars, resolution XP and double-joins. Persisted tests in
tests/unit/services/opinionwar, tests/api/battles, tests/e2e/battles
and tests/api/posts/create.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
183 lines
10 KiB
Python
183 lines
10 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from datetime import datetime, timezone
|
|
from devplacepy.database import get_table
|
|
from devplacepy.utils.text import generate_uid
|
|
from devplacepy.utils.notifications import create_notification
|
|
|
|
LEVEL_BADGES = {
|
|
5: "Level 5",
|
|
10: "Level 10",
|
|
25: "Level 25",
|
|
50: "Level 50",
|
|
100: "Level 100",
|
|
}
|
|
|
|
BADGE_CATALOG = {
|
|
"Member": {"icon": "✦", "description": "Joined DevPlace", "group": "Milestones"},
|
|
"First Post": {"icon": "✎", "description": "Published a first post", "group": "First steps"},
|
|
"First Comment": {"icon": "❝", "description": "Wrote a first comment", "group": "First steps"},
|
|
"First Project": {"icon": "⬢", "description": "Shared a first project", "group": "First steps"},
|
|
"First Gist": {"icon": "❡", "description": "Shared a first gist", "group": "First steps"},
|
|
"Prolific": {"icon": "✺", "description": "Published 10 posts", "group": "Content"},
|
|
"Wordsmith": {"icon": "🖋", "description": "Published 50 posts", "group": "Content"},
|
|
"Veteran": {"icon": "🏆", "description": "Published 100 posts", "group": "Content"},
|
|
"Conversationalist": {"icon": "💬", "description": "Wrote 25 comments", "group": "Content"},
|
|
"Debater": {"icon": "📣", "description": "Wrote 100 comments", "group": "Content"},
|
|
"Builder": {"icon": "🔧", "description": "Shared 5 projects", "group": "Content"},
|
|
"Architect": {"icon": "🏛", "description": "Shared 10 projects", "group": "Content"},
|
|
"Snippet Collector": {"icon": "📑", "description": "Shared 5 gists", "group": "Content"},
|
|
"Rising Star": {"icon": "☆", "description": "Earned 25 stars", "group": "Reputation"},
|
|
"Star Author": {"icon": "★", "description": "Earned 100 stars", "group": "Reputation"},
|
|
"Superstar": {"icon": "🌟", "description": "Earned 500 stars", "group": "Reputation"},
|
|
"Popular": {"icon": "◎", "description": "Reached 10 followers", "group": "Reputation"},
|
|
"Influencer": {"icon": "📢", "description": "Reached 50 followers", "group": "Reputation"},
|
|
"Celebrity": {"icon": "👑", "description": "Reached 100 followers", "group": "Reputation"},
|
|
"Connector": {"icon": "🔗", "description": "Followed 10 people", "group": "Community"},
|
|
"On Fire": {"icon": "🔥", "description": "Maintained a 7-day activity streak", "group": "Dedication"},
|
|
"Dedicated": {"icon": "📅", "description": "Maintained a 30-day activity streak", "group": "Dedication"},
|
|
"Unstoppable": {"icon": "🚀", "description": "Maintained a 100-day activity streak", "group": "Dedication"},
|
|
"Level 5": {"icon": "❖", "description": "Reached level 5", "group": "Levels"},
|
|
"Level 10": {"icon": "❖", "description": "Reached level 10", "group": "Levels"},
|
|
"Level 25": {"icon": "❖", "description": "Reached level 25", "group": "Levels"},
|
|
"Level 50": {"icon": "❖", "description": "Reached level 50", "group": "Levels"},
|
|
"Level 100": {"icon": "❖", "description": "Reached level 100", "group": "Levels"},
|
|
"Curious": {"icon": "📖", "description": "Read your first documentation page", "group": "Explorer"},
|
|
"Studious": {"icon": "📚", "description": "Read 5 documentation pages", "group": "Explorer"},
|
|
"Scholar": {"icon": "🎓", "description": "Read 15 documentation pages", "group": "Explorer"},
|
|
"AI Curious": {"icon": "🤖", "description": "Talked to Devii for the first time", "group": "Explorer"},
|
|
"AI Whisperer": {"icon": "✨", "description": "Had 25 conversations with Devii", "group": "Explorer"},
|
|
"First Fork": {"icon": "🍴", "description": "Forked a project for the first time", "group": "Explorer"},
|
|
"Forker": {"icon": "🌿", "description": "Forked 5 projects", "group": "Explorer"},
|
|
"Archivist": {"icon": "🗜", "description": "Downloaded a project archive", "group": "Explorer"},
|
|
"SEO Auditor": {"icon": "🔍", "description": "Ran an SEO diagnostics audit", "group": "Explorer"},
|
|
"Slop Hunter": {"icon": "🧪", "description": "Ran a AI usage analysis", "group": "Explorer"},
|
|
"Researcher": {"icon": "🔬", "description": "Ran a DeepSearch investigation", "group": "Explorer"},
|
|
"Deep Diver": {"icon": "🌊", "description": "Ran 10 DeepSearch investigations", "group": "Explorer"},
|
|
"Container Captain": {"icon": "📦", "description": "Created a container instance", "group": "Explorer"},
|
|
"Instigator": {"icon": "🎺", "description": "Started an Opinion War", "group": "Engagement"},
|
|
"First Blood": {"icon": "⚔", "description": "Fought in an Opinion War", "group": "Engagement"},
|
|
"War Veteran": {"icon": "🛡", "description": "Fought 50 Opinion War battles", "group": "Engagement"},
|
|
"Champion": {"icon": "🏅", "description": "Won an Opinion War", "group": "Engagement"},
|
|
"Messenger": {"icon": "✉", "description": "Sent your first direct message", "group": "Community"},
|
|
"Chatterbox": {"icon": "📨", "description": "Sent 100 direct messages", "group": "Community"},
|
|
"Bookmarker": {"icon": "🔖", "description": "Bookmarked your first item", "group": "Engagement"},
|
|
"Curator": {"icon": "📌", "description": "Bookmarked 25 items", "group": "Engagement"},
|
|
"First Reaction": {"icon": "💛", "description": "Reacted to content for the first time", "group": "Engagement"},
|
|
"Cheerleader": {"icon": "🎉", "description": "Reacted 50 times", "group": "Engagement"},
|
|
"First Star Given": {"icon": "⭐", "description": "Starred someone's work for the first time", "group": "Engagement"},
|
|
"Supporter": {"icon": "👍", "description": "Gave 50 stars", "group": "Engagement"},
|
|
"Patron": {"icon": "🤝", "description": "Gave 250 stars", "group": "Engagement"},
|
|
"Friendly": {"icon": "👋", "description": "Followed someone for the first time", "group": "Community"},
|
|
"First Upload": {"icon": "📎", "description": "Uploaded your first attachment", "group": "First steps"},
|
|
"File Creator": {"icon": "📄", "description": "Created a file in a project", "group": "First steps"},
|
|
"Bug Reporter": {"icon": "🐛", "description": "Filed your first issue", "group": "Community"},
|
|
"Pollster": {"icon": "🗳", "description": "Voted in a poll for the first time", "group": "Engagement"},
|
|
"Profiled": {"icon": "📇", "description": "Customized your profile", "group": "First steps"},
|
|
"Green Thumb": {"icon": "🌱", "description": "Harvested your first build on the Code Farm", "group": "Code Farm"},
|
|
"Master Farmer": {"icon": "🌾", "description": "Harvested 50 builds on the Code Farm", "group": "Code Farm"},
|
|
"Good Neighbor": {"icon": "💧", "description": "Watered a neighbour's build", "group": "Code Farm"},
|
|
"Cat Burglar": {"icon": "🥷", "description": "Stole a ready build from another farm", "group": "Code Farm"},
|
|
"Robbed": {"icon": "🚨", "description": "Had a build stolen from your farm", "group": "Code Farm"},
|
|
"Enterprise Ready": {"icon": "📦", "description": "Bought your first Code Farm Infrastructure building", "group": "Code Farm"},
|
|
"Fort Knox": {"icon": "🏰", "description": "Upgraded your Code Farm Defense for the first time", "group": "Code Farm"},
|
|
"Style Points": {"icon": "💅", "description": "Bought your first Code Farm cosmetic", "group": "Code Farm"},
|
|
"David vs Goliath": {"icon": "🪃", "description": "Raided a farm with 10x your coins and earned an Underdog boost", "group": "Code Farm"},
|
|
"First Quiz": {"icon": "🧩", "description": "Created your first quiz", "group": "Quizzes"},
|
|
"Quiz Author": {"icon": "✍️", "description": "Published your first quiz", "group": "Quizzes"},
|
|
"Quiz Master": {"icon": "🎓", "description": "Published 10 quizzes", "group": "Quizzes"},
|
|
"Quiz Taker": {"icon": "📚", "description": "Completed your first quiz", "group": "Quizzes"},
|
|
"Quiz Regular": {"icon": "🧠", "description": "Completed 25 quizzes", "group": "Quizzes"},
|
|
"Flawless": {"icon": "🎖️", "description": "Scored 100% on a quiz", "group": "Quizzes"},
|
|
}
|
|
|
|
BADGE_GROUPS = [
|
|
"First steps",
|
|
"Explorer",
|
|
"Engagement",
|
|
"Content",
|
|
"Community",
|
|
"Reputation",
|
|
"Dedication",
|
|
"Code Farm",
|
|
"Quizzes",
|
|
"Levels",
|
|
"Milestones",
|
|
]
|
|
|
|
|
|
def get_badge(badge_name: str) -> dict:
|
|
return BADGE_CATALOG.get(
|
|
badge_name, {"icon": "✦", "description": badge_name, "group": "Milestones"}
|
|
)
|
|
|
|
|
|
def build_achievements(held: set) -> list:
|
|
grouped: dict[str, list] = {group: [] for group in BADGE_GROUPS}
|
|
for name, meta in BADGE_CATALOG.items():
|
|
group = meta.get("group", "Milestones")
|
|
grouped.setdefault(group, []).append(
|
|
{
|
|
"name": name,
|
|
"icon": meta["icon"],
|
|
"description": meta["description"],
|
|
"earned": name in held,
|
|
}
|
|
)
|
|
showcase = []
|
|
for group in BADGE_GROUPS:
|
|
items = grouped.get(group) or []
|
|
if not items:
|
|
continue
|
|
showcase.append(
|
|
{
|
|
"group": group,
|
|
"items": items,
|
|
"earned": sum(1 for item in items if item["earned"]),
|
|
"total": len(items),
|
|
}
|
|
)
|
|
return showcase
|
|
|
|
|
|
def award_badge(user_uid: str, badge_name: str) -> bool:
|
|
badges = get_table("badges")
|
|
if badges.find_one(user_uid=user_uid, badge_name=badge_name):
|
|
return False
|
|
badge_uid = generate_uid()
|
|
badges.insert(
|
|
{
|
|
"uid": badge_uid,
|
|
"user_uid": user_uid,
|
|
"badge_name": badge_name,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
audit.record_system(
|
|
"reward.badge.award",
|
|
actor_kind="user",
|
|
actor_uid=user_uid,
|
|
summary=f"user earned the {badge_name} badge",
|
|
metadata={"badge": badge_name},
|
|
links=[
|
|
audit.actor(user_uid),
|
|
audit.link("target", "badge", badge_uid, badge_name),
|
|
],
|
|
)
|
|
return True
|
|
|
|
|
|
def notify_badge(user_uid: str, badge_name: str) -> None:
|
|
user = get_table("users").find_one(uid=user_uid)
|
|
if not user:
|
|
return
|
|
create_notification(
|
|
user_uid,
|
|
"badge",
|
|
f"You earned the {badge_name} badge",
|
|
user_uid,
|
|
f"/profile/{user['username']}",
|
|
)
|