Files
devplacepy/devplacepy/utils/rewards.py
T
blindxfishandClaude Fable 5 80956ce0f4 Add Opinion Wars: week-long two-faction battles attached to posts
A new post attachment type beside polls: the composer gains a Start
Opinion War builder (same disabled-inputs opt-in as the poll builder)
that names exactly two factions; the battle runs for exactly 7 days
from post creation. Members join a side, may defect at any time
(damage already dealt stays with the faction it was dealt to), and
fight once per 24 hours per battle. A fight spends 25 Code Farm coins
and deals deterministic level-weighted damage: 100 + 10 * min(level,
20) HP, so a newcomer deals 110 and a veteran caps at 300 - no
randomness anywhere.

The battle renders on the post card as a CSS pixel-art battlefield
(box-shadow sprites: castles, faction flags, marching soldiers, a
flickering campfire; steps() animation, disabled under reduced motion)
with live HP bars, a countdown, the viewer's faction strip, top
contributors and an event ticker. Live frames ride pub/sub on
public.battle.{uid} via a relay on the service-lock owner, with the
durable opinion_war_events trail (per-war atomic seq) as the source of
truth and a 15s incremental poller as fallback. /battles lists battles
with active/ended/mine filters, search and pagination.

Every mutation is a conditional UPDATE via conditional_update_row: the
fight sequence claims the cooldown first, then spends coins, then lands
the damage, compensating earlier steps on any later refusal so a crash
costs a turn, never coins. Resolution is lazy on read (no cron):
an exactly-once CAS computes the winner in the statement, awards XP
(participation, winner bonus, top damage dealer bonus; draws pay
participation only), emits the result event and notifies fighters. The
OpinionWarService backstop resolves unviewed wars and sends
fight-ready notifications, exactly-once via a marker CAS.

Fan-out: battle notification type, four badges, audit keys
(battle.create/join/switch/fight/resolve), Devii actions (join/fight
confirm-gated), API docs group, docs prose page, sitemap and topnav
entries, REPORTABLE_TARGETS registration, post-delete cascades,
README and nested CLAUDE.md documentation.

Verified with the four-layer procedure: property checks over the full
damage domain, 1200-step stateful fuzz (hp-sum invariant, coins never
negative, resolved totals frozen), and real 8-process races proving
exactly-once semantics for concurrent fights, double-spends across two
wars, resolution XP and double-joins. Persisted tests in
tests/unit/services/opinionwar, tests/api/battles, tests/e2e/battles
and tests/api/posts/create.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:30:02 +02:00

233 lines
7.9 KiB
Python

# retoor <retoor@molodetz.nl>
from devplacepy.database import (
get_table,
get_user_stars,
record_activity,
record_unique_activity,
)
from devplacepy.services.background import background
from devplacepy.utils.authcache import clear_user_cache
from devplacepy.utils.badges import award_badge, notify_badge, LEVEL_BADGES
from devplacepy.utils.notifications import create_notification
LEVEL_XP = 100
XP_POST = 10
XP_COMMENT = 2
XP_PROJECT = 15
XP_GIST = 5
XP_UPVOTE = 5
XP_FOLLOW = 5
XP_QUIZ = 8
XP_QUIZ_PUBLISH = 12
XP_QUIZ_COMPLETE = 4
XP_BATTLE_PART = 5
XP_BATTLE_WIN = 15
XP_BATTLE_TOP = 10
def level_for_xp(xp: int) -> int:
return 1 + max(0, xp) // LEVEL_XP
def award_xp(user_uid: str, amount: int) -> dict:
users = get_table("users")
user = users.find_one(uid=user_uid)
if not user or amount <= 0:
return {
"xp": user.get("xp", 0) if user else 0,
"level": user.get("level", 1) if user else 1,
"leveled_up": False,
}
current_xp = user.get("xp", 0) or 0
current_level = user.get("level", 1) or 1
new_xp = max(0, current_xp + amount)
new_level = level_for_xp(new_xp)
users.update({"uid": user_uid, "xp": new_xp, "level": new_level}, ["uid"])
clear_user_cache(user_uid, propagate=False)
leveled_up = new_level > current_level
from devplacepy.services.audit import record as audit
audit.record_system(
"reward.xp.grant",
actor_kind="user",
actor_uid=user_uid,
new_value=amount,
metadata={"amount": amount, "total_xp": new_xp},
summary=f"user earned {amount} XP",
links=[audit.actor(user_uid)],
)
if leveled_up:
audit.record_system(
"reward.level.up",
actor_kind="user",
actor_uid=user_uid,
old_value=current_level,
new_value=new_level,
metadata={"level": new_level},
summary=f"user reached level {new_level}",
links=[audit.actor(user_uid)],
)
create_notification(
user_uid,
"level",
f"You reached level {new_level}",
user_uid,
f"/profile/{user['username']}",
)
for level in range(current_level + 1, new_level + 1):
badge_name = LEVEL_BADGES.get(level)
if badge_name and award_badge(user_uid, badge_name):
notify_badge(user_uid, badge_name)
return {"xp": new_xp, "level": new_level, "leveled_up": leveled_up}
_COUNT_MILESTONES = [
("Prolific", "posts", 10),
("Wordsmith", "posts", 50),
("Veteran", "posts", 100),
("Conversationalist", "comments", 25),
("Debater", "comments", 100),
("Builder", "projects", 5),
("Architect", "projects", 10),
("Snippet Collector", "gists", 5),
("Rising Star", "stars", 25),
("Star Author", "stars", 100),
("Superstar", "stars", 500),
("Popular", "followers", 10),
("Influencer", "followers", 50),
("Celebrity", "followers", 100),
("Connector", "following", 10),
("On Fire", "streak", 7),
("Dedicated", "streak", 30),
("Unstoppable", "streak", 100),
]
def _milestone_metrics(user_uid: str):
cache: dict[str, int] = {}
def resolve(key: str) -> int:
if key in cache:
return cache[key]
if key == "posts":
value = get_table("posts").count(user_uid=user_uid, deleted_at=None)
elif key == "comments":
value = get_table("comments").count(user_uid=user_uid, deleted_at=None)
elif key == "projects":
value = get_table("projects").count(user_uid=user_uid, deleted_at=None)
elif key == "gists":
value = get_table("gists").count(user_uid=user_uid, deleted_at=None)
elif key == "stars":
value = get_user_stars(user_uid)
elif key == "followers":
value = get_table("follows").count(following_uid=user_uid, deleted_at=None)
elif key == "following":
value = get_table("follows").count(follower_uid=user_uid, deleted_at=None)
elif key == "streak":
from devplacepy.database import get_streaks
value = get_streaks(user_uid)["current"]
else:
value = 0
cache[key] = value
return value
return resolve
def check_milestone_badges(user_uid: str) -> list:
held = {row["badge_name"] for row in get_table("badges").find(user_uid=user_uid)}
pending = [m for m in _COUNT_MILESTONES if m[0] not in held]
if not pending:
return []
metric = _milestone_metrics(user_uid)
awarded = []
for badge_name, metric_key, threshold in pending:
if metric(metric_key) >= threshold and award_badge(user_uid, badge_name):
awarded.append(badge_name)
if metric_key == "streak":
from devplacepy.services.audit import record as audit
audit.record_system(
"reward.streak.milestone",
actor_kind="user",
actor_uid=user_uid,
metadata={"streak": metric("streak")},
summary=f"user reached a {metric('streak')}-day streak",
links=[audit.actor(user_uid)],
)
for badge_name in awarded:
notify_badge(user_uid, badge_name)
return awarded
def award_rewards(user_uid: str, amount: int, first_badge: str | None = None) -> None:
background.submit(_apply_rewards, user_uid, amount, first_badge)
def _apply_rewards(user_uid: str, amount: int, first_badge: str | None = None) -> None:
if first_badge:
award_badge(user_uid, first_badge)
award_xp(user_uid, amount)
check_milestone_badges(user_uid)
ACHIEVEMENTS = {
"docs.read": [(1, "Curious"), (5, "Studious"), (15, "Scholar")],
"devii": [(1, "AI Curious"), (25, "AI Whisperer")],
"fork": [(1, "First Fork"), (5, "Forker")],
"battle_create": [(1, "Instigator")],
"battle_fight": [(1, "First Blood"), (50, "War Veteran")],
"battle_win": [(1, "Champion")],
"zip": [(1, "Archivist")],
"seo": [(1, "SEO Auditor")],
"isslop": [(1, "Slop Hunter")],
"deepsearch": [(1, "Researcher"), (10, "Deep Diver")],
"container": [(1, "Container Captain")],
"message": [(1, "Messenger"), (100, "Chatterbox")],
"bookmark": [(1, "Bookmarker"), (25, "Curator")],
"reaction": [(1, "First Reaction"), (50, "Cheerleader")],
"vote": [(1, "First Star Given"), (50, "Supporter"), (250, "Patron")],
"follow": [(1, "Friendly")],
"upload": [(1, "First Upload")],
"project_file": [(1, "File Creator")],
"issue": [(1, "Bug Reporter")],
"poll": [(1, "Pollster")],
"profile": [(1, "Profiled")],
"harvest": [(1, "Green Thumb"), (50, "Master Farmer")],
"water": [(1, "Good Neighbor")],
"harvest_stolen": [(1, "Cat Burglar")],
"got_stolen_from": [(1, "Robbed")],
"infra_bought": [(1, "Enterprise Ready")],
"defense_upgraded": [(1, "Fort Knox")],
"cosmetic_bought": [(1, "Style Points")],
"underdog_raid": [(1, "David vs Goliath")],
"quiz_publish": [(1, "Quiz Author"), (10, "Quiz Master")],
"quiz_complete": [(1, "Quiz Taker"), (25, "Quiz Regular")],
"quiz_perfect": [(1, "Flawless")],
}
UNIQUE_ACTIONS = {"docs.read"}
def track_action(user_uid: str, action: str, target: str | None = None) -> None:
if not user_uid or action not in ACHIEVEMENTS:
return
background.submit(_apply_achievements, user_uid, action, target)
def _apply_achievements(user_uid: str, action: str, target: str | None) -> None:
if action in UNIQUE_ACTIONS:
if target is None:
return
count = record_unique_activity(user_uid, action, target)
else:
count = record_activity(user_uid, action)
if not count:
return
for threshold, badge_name in ACHIEVEMENTS.get(action, []):
if count >= threshold and award_badge(user_uid, badge_name):
notify_badge(user_uid, badge_name)