Coverage for integrations / social / gamification_service.py: 30.7%
202 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - Gamification Service
3Achievements, challenges, seasons, streaks, collectibles.
4"""
5import json
6import logging
7from datetime import datetime
8from typing import Optional, Dict, List
10from sqlalchemy import desc, func
11from sqlalchemy.orm import Session
13from .models import (
14 User, Achievement, UserAchievement, Season, Challenge, UserChallenge,
15 ResonanceWallet,
16)
17from .resonance_engine import ResonanceService
19logger = logging.getLogger('hevolve_social')
21# ─── Achievement Definitions ───
23SEED_ACHIEVEMENTS = [
24 # Onboarding
25 {'slug': 'welcome', 'name': 'Welcome!', 'description': 'Complete onboarding', 'category': 'onboarding', 'rarity': 'common', 'pulse_reward': 50, 'xp_reward': 25,
26 'criteria_json': json.dumps({'type': 'onboarding_complete'})},
27 {'slug': 'first_post', 'name': 'First Words', 'description': 'Create your first post', 'category': 'content', 'rarity': 'common', 'pulse_reward': 25, 'xp_reward': 15,
28 'criteria_json': json.dumps({'type': 'post_count', 'threshold': 1})},
29 {'slug': 'first_comment', 'name': 'Joining the Conversation', 'description': 'Leave your first comment', 'category': 'content', 'rarity': 'common', 'pulse_reward': 15, 'xp_reward': 10,
30 'criteria_json': json.dumps({'type': 'comment_count', 'threshold': 1})},
31 {'slug': 'first_follow', 'name': 'Connected', 'description': 'Follow your first user or agent', 'category': 'social', 'rarity': 'common', 'pulse_reward': 10, 'xp_reward': 10,
32 'criteria_json': json.dumps({'type': 'following_count', 'threshold': 1})},
34 # Content milestones
35 {'slug': 'prolific_10', 'name': 'Prolific Writer', 'description': 'Create 10 posts', 'category': 'content', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 25, 'xp_reward': 50,
36 'criteria_json': json.dumps({'type': 'post_count', 'threshold': 10})},
37 {'slug': 'prolific_50', 'name': 'Content Machine', 'description': 'Create 50 posts', 'category': 'content', 'rarity': 'rare', 'pulse_reward': 150, 'spark_reward': 75, 'xp_reward': 150,
38 'criteria_json': json.dumps({'type': 'post_count', 'threshold': 50})},
39 {'slug': 'prolific_200', 'name': 'Legendary Author', 'description': 'Create 200 posts', 'category': 'content', 'rarity': 'legendary', 'pulse_reward': 500, 'spark_reward': 250, 'xp_reward': 500,
40 'criteria_json': json.dumps({'type': 'post_count', 'threshold': 200})},
42 # Social milestones
43 {'slug': 'popular_10', 'name': 'Getting Noticed', 'description': 'Gain 10 followers', 'category': 'social', 'rarity': 'common', 'pulse_reward': 25, 'xp_reward': 25,
44 'criteria_json': json.dumps({'type': 'follower_count', 'threshold': 10})},
45 {'slug': 'popular_50', 'name': 'Rising Star', 'description': 'Gain 50 followers', 'category': 'social', 'rarity': 'uncommon', 'pulse_reward': 75, 'spark_reward': 30, 'xp_reward': 75,
46 'criteria_json': json.dumps({'type': 'follower_count', 'threshold': 50})},
47 {'slug': 'popular_200', 'name': 'Influencer', 'description': 'Gain 200 followers', 'category': 'social', 'rarity': 'rare', 'pulse_reward': 200, 'spark_reward': 100, 'xp_reward': 200,
48 'criteria_json': json.dumps({'type': 'follower_count', 'threshold': 200})},
49 {'slug': 'popular_1000', 'name': 'Celebrity', 'description': 'Gain 1000 followers', 'category': 'social', 'rarity': 'legendary', 'pulse_reward': 750, 'spark_reward': 500, 'xp_reward': 750,
50 'criteria_json': json.dumps({'type': 'follower_count', 'threshold': 1000})},
52 # Streak
53 {'slug': 'streak_7', 'name': 'Week Warrior', 'description': '7-day login streak', 'category': 'streak', 'rarity': 'uncommon', 'spark_reward': 50, 'xp_reward': 50,
54 'criteria_json': json.dumps({'type': 'streak_days', 'threshold': 7})},
55 {'slug': 'streak_30', 'name': 'Monthly Devotee', 'description': '30-day login streak', 'category': 'streak', 'rarity': 'rare', 'spark_reward': 200, 'xp_reward': 200,
56 'criteria_json': json.dumps({'type': 'streak_days', 'threshold': 30})},
57 {'slug': 'streak_100', 'name': 'Centurion', 'description': '100-day login streak', 'category': 'streak', 'rarity': 'legendary', 'spark_reward': 1000, 'xp_reward': 1000,
58 'criteria_json': json.dumps({'type': 'streak_days', 'threshold': 100})},
60 # Agent-specific
61 {'slug': 'agent_creator', 'name': 'Creator', 'description': 'Create your first agent', 'category': 'agent', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 100, 'xp_reward': 100,
62 'criteria_json': json.dumps({'type': 'agent_created', 'threshold': 1})},
63 {'slug': 'recipe_master', 'name': 'Recipe Master', 'description': 'Share 5 recipes', 'category': 'agent', 'rarity': 'rare', 'spark_reward': 150, 'xp_reward': 150,
64 'criteria_json': json.dumps({'type': 'recipe_shared', 'threshold': 5})},
66 # Task completion
67 {'slug': 'task_completer', 'name': 'Task Doer', 'description': 'Complete 5 tasks', 'category': 'task', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 50, 'xp_reward': 75,
68 'criteria_json': json.dumps({'type': 'task_completed', 'threshold': 5})},
69 {'slug': 'task_master', 'name': 'Task Master', 'description': 'Complete 50 tasks', 'category': 'task', 'rarity': 'rare', 'pulse_reward': 200, 'spark_reward': 200, 'xp_reward': 300,
70 'criteria_json': json.dumps({'type': 'task_completed', 'threshold': 50})},
72 # Reputation
73 {'slug': 'trusted', 'name': 'Trusted Member', 'description': 'Reach Signal 1.0', 'category': 'reputation', 'rarity': 'uncommon', 'pulse_reward': 50, 'xp_reward': 50,
74 'criteria_json': json.dumps({'type': 'signal_threshold', 'threshold': 1.0})},
75 {'slug': 'authority', 'name': 'Authority', 'description': 'Reach Signal 5.0', 'category': 'reputation', 'rarity': 'rare', 'pulse_reward': 150, 'xp_reward': 150,
76 'criteria_json': json.dumps({'type': 'signal_threshold', 'threshold': 5.0})},
77 {'slug': 'pillar', 'name': 'Community Pillar', 'description': 'Reach Signal 20.0', 'category': 'reputation', 'rarity': 'legendary', 'pulse_reward': 500, 'spark_reward': 250, 'xp_reward': 500,
78 'criteria_json': json.dumps({'type': 'signal_threshold', 'threshold': 20.0})},
80 # Referrals
81 {'slug': 'referrer_1', 'name': 'Advocate', 'description': 'Refer 1 activated user', 'category': 'growth', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 100, 'xp_reward': 75,
82 'criteria_json': json.dumps({'type': 'referral_activated', 'threshold': 1})},
83 {'slug': 'referrer_10', 'name': 'Ambassador', 'description': 'Refer 10 activated users', 'category': 'growth', 'rarity': 'rare', 'pulse_reward': 200, 'spark_reward': 500, 'xp_reward': 300,
84 'criteria_json': json.dumps({'type': 'referral_activated', 'threshold': 10})},
86 # Campaigns
87 {'slug': 'first_campaign', 'name': 'Marketer', 'description': 'Launch your first campaign', 'category': 'campaign', 'rarity': 'uncommon', 'spark_reward': 50, 'xp_reward': 75,
88 'criteria_json': json.dumps({'type': 'campaign_launched', 'threshold': 1})},
90 # Encounter / bond
91 {'slug': 'first_encounter', 'name': 'Serendipity', 'description': 'Your first encounter', 'category': 'social', 'rarity': 'common', 'pulse_reward': 10, 'xp_reward': 15,
92 'criteria_json': json.dumps({'type': 'encounter_count', 'threshold': 1})},
93 {'slug': 'bond_5', 'name': 'Deep Bond', 'description': 'Reach bond level 5 with someone', 'category': 'social', 'rarity': 'rare', 'pulse_reward': 100, 'xp_reward': 100,
94 'criteria_json': json.dumps({'type': 'max_bond_level', 'threshold': 5})},
96 # Leveling
97 {'slug': 'level_5', 'name': 'Regular', 'description': 'Reach Level 5', 'category': 'leveling', 'rarity': 'common', 'spark_reward': 25, 'xp_reward': 0,
98 'criteria_json': json.dumps({'type': 'level', 'threshold': 5})},
99 {'slug': 'level_10', 'name': 'Veteran', 'description': 'Reach Level 10', 'category': 'leveling', 'rarity': 'uncommon', 'spark_reward': 100, 'xp_reward': 0,
100 'criteria_json': json.dumps({'type': 'level', 'threshold': 10})},
101 {'slug': 'level_20', 'name': 'Master', 'description': 'Reach Level 20', 'category': 'leveling', 'rarity': 'rare', 'spark_reward': 300, 'xp_reward': 0,
102 'criteria_json': json.dumps({'type': 'level', 'threshold': 20})},
103 {'slug': 'level_50', 'name': 'Founding Pillar', 'description': 'Reach Level 50', 'category': 'leveling', 'rarity': 'legendary', 'spark_reward': 2000, 'xp_reward': 0,
104 'criteria_json': json.dumps({'type': 'level', 'threshold': 50})},
106 # Community
107 {'slug': 'community_creator', 'name': 'Community Builder', 'description': 'Create a community', 'category': 'community', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 50, 'xp_reward': 75,
108 'criteria_json': json.dumps({'type': 'community_created', 'threshold': 1})},
109 {'slug': 'region_pioneer', 'name': 'Regional Pioneer', 'description': 'Join a region', 'category': 'community', 'rarity': 'common', 'pulse_reward': 15, 'xp_reward': 20,
110 'criteria_json': json.dumps({'type': 'region_joined', 'threshold': 1})},
111 {'slug': 'governor', 'name': 'Governor', 'description': 'Become a region moderator', 'category': 'community', 'rarity': 'rare', 'pulse_reward': 100, 'spark_reward': 100, 'xp_reward': 150,
112 'criteria_json': json.dumps({'type': 'region_role', 'role': 'moderator'})},
114 # Voting/karma
115 {'slug': 'upvotes_100', 'name': 'Appreciated', 'description': 'Receive 100 upvotes', 'category': 'reputation', 'rarity': 'uncommon', 'pulse_reward': 50, 'xp_reward': 50,
116 'criteria_json': json.dumps({'type': 'upvotes_received', 'threshold': 100})},
117 {'slug': 'upvotes_1000', 'name': 'Beloved', 'description': 'Receive 1000 upvotes', 'category': 'reputation', 'rarity': 'rare', 'pulse_reward': 200, 'spark_reward': 100, 'xp_reward': 200,
118 'criteria_json': json.dumps({'type': 'upvotes_received', 'threshold': 1000})},
120 # Boosting
121 {'slug': 'first_boost', 'name': 'Rocket Fuel', 'description': 'Boost content for the first time', 'category': 'economy', 'rarity': 'common', 'xp_reward': 20,
122 'criteria_json': json.dumps({'type': 'boost_count', 'threshold': 1})},
123 {'slug': 'big_spender', 'name': 'Big Spender', 'description': 'Spend 1000 Spark on boosts', 'category': 'economy', 'rarity': 'rare', 'pulse_reward': 100, 'xp_reward': 150,
124 'criteria_json': json.dumps({'type': 'spark_spent', 'threshold': 1000})},
126 # Multiplayer games
127 {'slug': 'first_game', 'name': 'Player One', 'description': 'Play your first multiplayer game', 'category': 'game', 'rarity': 'common', 'pulse_reward': 25, 'xp_reward': 20,
128 'criteria_json': json.dumps({'type': 'game_count', 'threshold': 1})},
129 {'slug': 'games_10', 'name': 'Regular Player', 'description': 'Play 10 games', 'category': 'game', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 25, 'xp_reward': 50,
130 'criteria_json': json.dumps({'type': 'game_count', 'threshold': 10})},
131 {'slug': 'games_50', 'name': 'Game Veteran', 'description': 'Play 50 games', 'category': 'game', 'rarity': 'rare', 'pulse_reward': 150, 'spark_reward': 75, 'xp_reward': 150,
132 'criteria_json': json.dumps({'type': 'game_count', 'threshold': 50})},
133 {'slug': 'game_win_streak_5', 'name': 'On Fire', 'description': 'Win 5 games in a row', 'category': 'game', 'rarity': 'rare', 'pulse_reward': 100, 'spark_reward': 50, 'xp_reward': 100,
134 'criteria_json': json.dumps({'type': 'game_win_streak', 'threshold': 5})},
135 {'slug': 'collab_master', 'name': 'Team Player', 'description': 'Complete 10 collaborative puzzles', 'category': 'game', 'rarity': 'uncommon', 'pulse_reward': 75, 'spark_reward': 50, 'xp_reward': 75,
136 'criteria_json': json.dumps({'type': 'collab_puzzle_count', 'threshold': 10})},
138 # Compute lending
139 {'slug': 'first_compute_share', 'name': 'Sharing is Caring', 'description': 'Enable compute sharing for the first time', 'category': 'compute', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 100, 'xp_reward': 100,
140 'criteria_json': json.dumps({'type': 'compute_opt_in'})},
141 {'slug': 'compute_1h', 'name': 'First Hour', 'description': 'Contribute 1 GPU-hour to the hive', 'category': 'compute', 'rarity': 'common', 'pulse_reward': 25, 'spark_reward': 50, 'xp_reward': 50,
142 'criteria_json': json.dumps({'type': 'compute_gpu_hours', 'threshold': 1})},
143 {'slug': 'compute_24h', 'name': 'Day Worker', 'description': 'Contribute 24 GPU-hours', 'category': 'compute', 'rarity': 'uncommon', 'pulse_reward': 100, 'spark_reward': 200, 'xp_reward': 200,
144 'criteria_json': json.dumps({'type': 'compute_gpu_hours', 'threshold': 24})},
145 {'slug': 'compute_100h', 'name': 'Hive Builder', 'description': 'Contribute 100 GPU-hours', 'category': 'compute', 'rarity': 'rare', 'pulse_reward': 300, 'spark_reward': 500, 'xp_reward': 500,
146 'criteria_json': json.dumps({'type': 'compute_gpu_hours', 'threshold': 100})},
147 {'slug': 'compute_1000h', 'name': 'Hive Pillar', 'description': 'Contribute 1000 GPU-hours', 'category': 'compute', 'rarity': 'legendary', 'pulse_reward': 1000, 'spark_reward': 2000, 'xp_reward': 2000,
148 'criteria_json': json.dumps({'type': 'compute_gpu_hours', 'threshold': 1000})},
149 {'slug': 'compute_helped_10', 'name': 'Helper', 'description': 'Your compute helped 10 different users', 'category': 'compute', 'rarity': 'uncommon', 'pulse_reward': 50, 'spark_reward': 75, 'xp_reward': 75,
150 'criteria_json': json.dumps({'type': 'compute_users_helped', 'threshold': 10})},
151]
154class GamificationService:
156 @staticmethod
157 def seed_achievements(db: Session) -> int:
158 """Seed initial achievements if not already present."""
159 count = 0
160 for ach_data in SEED_ACHIEVEMENTS:
161 existing = db.query(Achievement).filter_by(slug=ach_data['slug']).first()
162 if not existing:
163 ach = Achievement(
164 slug=ach_data['slug'],
165 name=ach_data['name'],
166 description=ach_data['description'],
167 icon_url=ach_data.get('icon_url', ''),
168 category=ach_data.get('category', 'general'),
169 rarity=ach_data.get('rarity', 'common'),
170 pulse_reward=ach_data.get('pulse_reward', 0),
171 spark_reward=ach_data.get('spark_reward', 0),
172 signal_reward=ach_data.get('signal_reward', 0.0),
173 xp_reward=ach_data.get('xp_reward', 0),
174 criteria_json=ach_data.get('criteria_json', '{}'),
175 is_seasonal=ach_data.get('is_seasonal', False),
176 )
177 db.add(ach)
178 count += 1
179 if count:
180 db.flush()
181 return count
183 @staticmethod
184 def get_all_achievements(db: Session) -> List[Dict]:
185 """Get all available achievements."""
186 achievements = db.query(Achievement).order_by(Achievement.category, Achievement.name).all()
187 return [a.to_dict() for a in achievements]
189 @staticmethod
190 def get_user_achievements(db: Session, user_id: str) -> List[Dict]:
191 """Get achievements unlocked by a user."""
192 rows = db.query(UserAchievement, Achievement).join(
193 Achievement, Achievement.id == UserAchievement.achievement_id
194 ).filter(UserAchievement.user_id == user_id).order_by(
195 desc(UserAchievement.unlocked_at)
196 ).all()
198 result = []
199 for ua, ach in rows:
200 entry = ach.to_dict()
201 entry['unlocked_at'] = ua.unlocked_at.isoformat() if ua.unlocked_at else None
202 entry['is_showcased'] = ua.is_showcased
203 result.append(entry)
204 return result
206 @staticmethod
207 def unlock_achievement(db: Session, user_id: str, achievement_slug: str) -> Optional[Dict]:
208 """Unlock an achievement for a user. Returns achievement dict or None if already unlocked."""
209 ach = db.query(Achievement).filter_by(slug=achievement_slug).first()
210 if not ach:
211 return None
213 existing = db.query(UserAchievement).filter_by(
214 user_id=user_id, achievement_id=ach.id
215 ).first()
216 if existing:
217 return None
219 ua = UserAchievement(
220 user_id=user_id,
221 achievement_id=ach.id,
222 unlocked_at=datetime.utcnow(),
223 )
224 db.add(ua)
225 db.flush()
227 # Award resonance rewards
228 if ach.pulse_reward:
229 ResonanceService.award_pulse(db, user_id, ach.pulse_reward,
230 'achievement', ach.id, f'Achievement: {ach.name}')
231 if ach.spark_reward:
232 ResonanceService.award_spark(db, user_id, ach.spark_reward,
233 'achievement', ach.id, f'Achievement: {ach.name}')
234 if ach.signal_reward:
235 ResonanceService.award_signal(db, user_id, ach.signal_reward,
236 'achievement', ach.id, f'Achievement: {ach.name}')
237 if ach.xp_reward:
238 ResonanceService.award_xp(db, user_id, ach.xp_reward,
239 'achievement', ach.id, f'Achievement: {ach.name}')
241 result = ach.to_dict()
242 result['unlocked_at'] = ua.unlocked_at.isoformat()
243 return result
245 @staticmethod
246 def toggle_showcase(db: Session, user_id: str, achievement_id: str) -> Optional[bool]:
247 """Toggle showcase flag on a user achievement."""
248 ua = db.query(UserAchievement).filter_by(
249 user_id=user_id, achievement_id=achievement_id
250 ).first()
251 if not ua:
252 return None
253 ua.is_showcased = not ua.is_showcased
254 return ua.is_showcased
256 @staticmethod
257 def check_achievements(db: Session, user_id: str, context: Dict = None) -> List[Dict]:
258 """Check and auto-unlock achievements based on current user state.
259 Called after significant actions (post, vote, follow, task complete, etc.)."""
260 wallet = db.query(ResonanceWallet).filter_by(user_id=user_id).first()
261 user = db.query(User).filter_by(id=user_id).first()
262 if not user:
263 return []
265 # Get already unlocked slugs
266 unlocked_slugs = set(
267 row[0] for row in db.query(Achievement.slug).join(
268 UserAchievement, UserAchievement.achievement_id == Achievement.id
269 ).filter(UserAchievement.user_id == user_id).all()
270 )
272 newly_unlocked = []
273 all_achievements = db.query(Achievement).filter(
274 ~Achievement.slug.in_(unlocked_slugs) if unlocked_slugs else True
275 ).all()
277 for ach in all_achievements:
278 if ach.slug in unlocked_slugs:
279 continue
281 try:
282 criteria = json.loads(ach.criteria_json) if ach.criteria_json else {}
283 except (json.JSONDecodeError, TypeError):
284 continue
286 ctype = criteria.get('type', '')
287 threshold = criteria.get('threshold', 0)
288 met = False
290 if ctype == 'post_count':
291 met = (user.post_count or 0) >= threshold
292 elif ctype == 'comment_count':
293 met = (user.comment_count or 0) >= threshold
294 elif ctype == 'follower_count':
295 met = (user.follower_count or 0) >= threshold
296 elif ctype == 'following_count':
297 met = (user.following_count or 0) >= threshold
298 elif ctype == 'streak_days' and wallet:
299 met = (wallet.streak_days or 0) >= threshold
300 elif ctype == 'signal_threshold' and wallet:
301 met = (wallet.signal or 0) >= threshold
302 elif ctype == 'level' and wallet:
303 met = (wallet.level or 1) >= threshold
304 elif ctype == 'upvotes_received':
305 met = (user.karma_score or 0) >= threshold
306 elif ctype == 'onboarding_complete':
307 # Check via context
308 met = context and context.get('onboarding_complete')
310 if met:
311 result = GamificationService.unlock_achievement(db, user_id, ach.slug)
312 if result:
313 newly_unlocked.append(result)
315 return newly_unlocked
317 # ─── Challenges ───
319 @staticmethod
320 def get_active_challenges(db: Session, user_id: str = None) -> List[Dict]:
321 """Get currently active challenges."""
322 now = datetime.utcnow()
323 challenges = db.query(Challenge).filter(
324 Challenge.starts_at <= now,
325 Challenge.ends_at >= now,
326 ).order_by(Challenge.ends_at).all()
328 result = []
329 for ch in challenges:
330 entry = ch.to_dict()
331 if user_id:
332 uc = db.query(UserChallenge).filter_by(
333 user_id=user_id, challenge_id=ch.id
334 ).first()
335 if uc:
336 entry['user_progress'] = uc.progress
337 entry['user_target'] = uc.target
338 entry['completed'] = uc.completed_at is not None
339 entry['rewarded'] = uc.rewarded
340 else:
341 entry['user_progress'] = 0
342 entry['completed'] = False
343 result.append(entry)
344 return result
346 @staticmethod
347 def get_challenge(db: Session, challenge_id: str, user_id: str = None) -> Optional[Dict]:
348 """Get challenge details with optional user progress."""
349 ch = db.query(Challenge).filter_by(id=challenge_id).first()
350 if not ch:
351 return None
352 entry = ch.to_dict()
353 if user_id:
354 uc = db.query(UserChallenge).filter_by(
355 user_id=user_id, challenge_id=ch.id
356 ).first()
357 if uc:
358 entry['user_progress'] = uc.progress
359 entry['user_target'] = uc.target
360 entry['completed'] = uc.completed_at is not None
361 entry['rewarded'] = uc.rewarded
362 return entry
364 @staticmethod
365 def update_challenge_progress(db: Session, user_id: str,
366 challenge_id: str, increment: int = 1) -> Optional[Dict]:
367 """Update progress on a challenge for a user."""
368 ch = db.query(Challenge).filter_by(id=challenge_id).first()
369 if not ch:
370 return None
372 uc = db.query(UserChallenge).filter_by(
373 user_id=user_id, challenge_id=ch.id
374 ).first()
375 if not uc:
376 # Auto-join
377 try:
378 criteria = json.loads(ch.criteria_json) if ch.criteria_json else {}
379 except (json.JSONDecodeError, TypeError):
380 criteria = {}
381 target = criteria.get('target', 10)
382 uc = UserChallenge(
383 user_id=user_id,
384 challenge_id=ch.id,
385 progress=0,
386 target=target,
387 )
388 db.add(uc)
389 db.flush()
391 if uc.completed_at:
392 return {'progress': uc.progress, 'target': uc.target, 'completed': True, 'already_complete': True}
394 uc.progress += increment
395 if uc.progress >= uc.target:
396 uc.completed_at = datetime.utcnow()
398 return {
399 'progress': uc.progress,
400 'target': uc.target,
401 'completed': uc.completed_at is not None,
402 }
404 @staticmethod
405 def claim_challenge_reward(db: Session, user_id: str,
406 challenge_id: str) -> Optional[Dict]:
407 """Claim rewards for a completed challenge."""
408 uc = db.query(UserChallenge).filter_by(
409 user_id=user_id, challenge_id=challenge_id
410 ).first()
411 if not uc or not uc.completed_at:
412 return None
413 if uc.rewarded:
414 return {'already_claimed': True}
416 ch = db.query(Challenge).filter_by(id=challenge_id).first()
417 if not ch:
418 return None
420 try:
421 rewards = json.loads(ch.rewards) if isinstance(ch.rewards, str) else (ch.rewards or {})
422 except (json.JSONDecodeError, TypeError):
423 rewards = {}
425 uc.rewarded = True
427 result = {'rewards': rewards}
428 if rewards.get('pulse'):
429 ResonanceService.award_pulse(db, user_id, rewards['pulse'],
430 'challenge', ch.id, f'Challenge: {ch.name}')
431 if rewards.get('spark'):
432 ResonanceService.award_spark(db, user_id, rewards['spark'],
433 'challenge', ch.id, f'Challenge: {ch.name}')
434 if rewards.get('signal'):
435 ResonanceService.award_signal(db, user_id, rewards['signal'],
436 'challenge', ch.id, f'Challenge: {ch.name}')
437 if rewards.get('xp'):
438 ResonanceService.award_xp(db, user_id, rewards['xp'],
439 'challenge', ch.id, f'Challenge: {ch.name}')
441 return result
443 # ─── Seasons ───
445 @staticmethod
446 def get_current_season(db: Session) -> Optional[Dict]:
447 """Get the currently active season."""
448 now = datetime.utcnow()
449 season = db.query(Season).filter(
450 Season.starts_at <= now,
451 Season.ends_at >= now,
452 Season.is_active == True,
453 ).first()
454 return season.to_dict() if season else None
456 @staticmethod
457 def get_season_leaderboard(db: Session, season_id: str,
458 limit: int = 50, offset: int = 0) -> List[Dict]:
459 """Get season leaderboard (by season_pulse + season_spark)."""
460 rows = db.query(ResonanceWallet, User).join(
461 User, User.id == ResonanceWallet.user_id
462 ).order_by(
463 desc(ResonanceWallet.season_pulse + ResonanceWallet.season_spark)
464 ).offset(offset).limit(limit).all()
466 result = []
467 for i, (wallet, user) in enumerate(rows, start=offset + 1):
468 result.append({
469 'rank': i,
470 'user_id': user.id,
471 'username': user.username,
472 'display_name': user.display_name,
473 'avatar_url': user.avatar_url,
474 'season_pulse': wallet.season_pulse,
475 'season_spark': wallet.season_spark,
476 'level': wallet.level,
477 'level_title': wallet.level_title,
478 })
479 return result
481 @staticmethod
482 def get_season_achievements(db: Session, season_id: str) -> List[Dict]:
483 """Get achievements for a specific season."""
484 achievements = db.query(Achievement).filter_by(
485 is_seasonal=True, season_id=season_id
486 ).all()
487 return [a.to_dict() for a in achievements]