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

1""" 

2HevolveSocial - Gamification Service 

3Achievements, challenges, seasons, streaks, collectibles. 

4""" 

5import json 

6import logging 

7from datetime import datetime 

8from typing import Optional, Dict, List 

9 

10from sqlalchemy import desc, func 

11from sqlalchemy.orm import Session 

12 

13from .models import ( 

14 User, Achievement, UserAchievement, Season, Challenge, UserChallenge, 

15 ResonanceWallet, 

16) 

17from .resonance_engine import ResonanceService 

18 

19logger = logging.getLogger('hevolve_social') 

20 

21# ─── Achievement Definitions ─── 

22 

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})}, 

33 

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})}, 

41 

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})}, 

51 

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})}, 

59 

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})}, 

65 

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})}, 

71 

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})}, 

79 

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})}, 

85 

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})}, 

89 

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})}, 

95 

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})}, 

105 

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'})}, 

113 

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})}, 

119 

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})}, 

125 

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})}, 

137 

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] 

152 

153 

154class GamificationService: 

155 

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 

182 

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] 

188 

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() 

197 

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 

205 

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 

212 

213 existing = db.query(UserAchievement).filter_by( 

214 user_id=user_id, achievement_id=ach.id 

215 ).first() 

216 if existing: 

217 return None 

218 

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() 

226 

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}') 

240 

241 result = ach.to_dict() 

242 result['unlocked_at'] = ua.unlocked_at.isoformat() 

243 return result 

244 

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 

255 

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 [] 

264 

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 ) 

271 

272 newly_unlocked = [] 

273 all_achievements = db.query(Achievement).filter( 

274 ~Achievement.slug.in_(unlocked_slugs) if unlocked_slugs else True 

275 ).all() 

276 

277 for ach in all_achievements: 

278 if ach.slug in unlocked_slugs: 

279 continue 

280 

281 try: 

282 criteria = json.loads(ach.criteria_json) if ach.criteria_json else {} 

283 except (json.JSONDecodeError, TypeError): 

284 continue 

285 

286 ctype = criteria.get('type', '') 

287 threshold = criteria.get('threshold', 0) 

288 met = False 

289 

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') 

309 

310 if met: 

311 result = GamificationService.unlock_achievement(db, user_id, ach.slug) 

312 if result: 

313 newly_unlocked.append(result) 

314 

315 return newly_unlocked 

316 

317 # ─── Challenges ─── 

318 

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() 

327 

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 

345 

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 

363 

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 

371 

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() 

390 

391 if uc.completed_at: 

392 return {'progress': uc.progress, 'target': uc.target, 'completed': True, 'already_complete': True} 

393 

394 uc.progress += increment 

395 if uc.progress >= uc.target: 

396 uc.completed_at = datetime.utcnow() 

397 

398 return { 

399 'progress': uc.progress, 

400 'target': uc.target, 

401 'completed': uc.completed_at is not None, 

402 } 

403 

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} 

415 

416 ch = db.query(Challenge).filter_by(id=challenge_id).first() 

417 if not ch: 

418 return None 

419 

420 try: 

421 rewards = json.loads(ch.rewards) if isinstance(ch.rewards, str) else (ch.rewards or {}) 

422 except (json.JSONDecodeError, TypeError): 

423 rewards = {} 

424 

425 uc.rewarded = True 

426 

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}') 

440 

441 return result 

442 

443 # ─── Seasons ─── 

444 

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 

455 

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() 

465 

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 

480 

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]