Coverage for integrations / social / resonance_engine.py: 60.2%
191 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 - Resonance Engine
3Multi-dimensional reward system: Pulse, Spark, Signal, XP.
4"""
5import logging
6import math
7from datetime import datetime, timedelta
8from typing import Optional, Dict, List, Tuple
10from sqlalchemy import desc, func
11from sqlalchemy.orm import Session
13from .models import (
14 User, ResonanceWallet, ResonanceTransaction
15)
17logger = logging.getLogger('hevolve_social')
19# ─── Level/Title System ───
21LEVEL_TITLES = {
22 1: 'Newcomer', 3: 'Contributor', 5: 'Regular', 8: 'Established',
23 10: 'Veteran', 15: 'Expert', 20: 'Master', 25: 'Luminary',
24 30: 'Legend', 40: 'Architect', 50: 'Founding Pillar',
25}
28def xp_for_level(n: int) -> int:
29 return int(100 * (1.15 ** (n - 1)))
32def title_for_level(level: int) -> str:
33 title = 'Newcomer'
34 for threshold, t in sorted(LEVEL_TITLES.items()):
35 if level >= threshold:
36 title = t
37 return title
40# ─── Award Tables ───
42AWARD_TABLE = {
43 'post_upvote': {'pulse': 1, 'xp': 2},
44 'create_post': {'spark': 5, 'signal': 0.01, 'xp': 10},
45 'create_comment': {'spark': 2, 'signal': 0.005, 'xp': 5},
46 'complete_task': {'pulse': 10, 'spark': 20, 'signal': 0.1, 'xp': 50},
47 'recipe_shared': {'spark': 15, 'signal': 0.05, 'xp': 25},
48 'recipe_forked': {'pulse': 5, 'spark': 10, 'signal': 0.02, 'xp': 15},
49 'referral_signup': {'pulse': 5, 'spark': 15, 'signal': 0.02, 'xp': 25},
50 'referral_activated': {'pulse': 20, 'spark': 50, 'signal': 0.1, 'xp': 100},
51 'correct_moderation': {'signal': 0.05, 'xp': 10},
52 'campaign_milestone': {'pulse': 5, 'spark': 25, 'signal': 0.03, 'xp': 30},
53 'ad_impression_served': {'spark': 1},
54 'hosting_uptime_bonus': {'spark': 10, 'pulse': 5, 'xp': 20},
55 'hosting_milestone': {'spark': 50, 'pulse': 25, 'xp': 100},
56 'learning_contribution': {'spark': 25, 'pulse': 10, 'signal': 0.08, 'xp': 40},
57 'learning_skill_shared': {'spark': 15, 'signal': 0.05, 'xp': 25},
58 'learning_credit_assigned': {'spark': 5, 'xp': 10},
59 'experiment_proposed': {'spark': 20, 'pulse': 10, 'signal': 0.10, 'xp': 30},
60 'experiment_voted': {'spark': 10, 'pulse': 5, 'xp': 15},
61 'experiment_evaluated': {'spark': 30, 'signal': 0.08, 'xp': 50},
62 'experiment_suggestion': {'spark': 15, 'signal': 0.05, 'xp': 20},
63 # Multiplayer games
64 'game_win': {'pulse': 15, 'spark': 10, 'xp': 30},
65 'game_participate': {'pulse': 5, 'spark': 5, 'xp': 15},
66 'game_streak_3': {'pulse': 10, 'spark': 15, 'xp': 25},
67 'multiplayer_encounter': {'pulse': 5, 'xp': 10},
68 # Compute lending
69 'compute_opt_in': {'pulse': 25, 'spark': 50, 'xp': 50},
70 'compute_hour': {'spark': 10, 'xp': 20},
71 'compute_day_streak': {'spark': 25, 'pulse': 10, 'xp': 40},
72 'experiment_pledge': {'pulse': 20, 'spark': 30, 'signal': 0.10, 'xp': 40},
73 # Content sharing
74 'content_shared': {'spark': 5, 'xp': 10},
75 'content_viral_10': {'spark': 25, 'xp': 20},
76 'content_viral_50': {'spark': 100, 'xp': 50},
77}
80class ResonanceService:
82 @staticmethod
83 def get_or_create_wallet(db: Session, user_id: str) -> ResonanceWallet:
84 wallet = db.query(ResonanceWallet).filter_by(user_id=user_id).first()
85 if not wallet:
86 wallet = ResonanceWallet(user_id=user_id)
87 db.add(wallet)
88 db.flush()
89 return wallet
91 @staticmethod
92 def get_wallet(db: Session, user_id: str) -> Optional[Dict]:
93 wallet = db.query(ResonanceWallet).filter_by(user_id=user_id).first()
94 if wallet:
95 return wallet.to_dict()
96 return None
98 @staticmethod
99 def _log_transaction(db: Session, user_id: str, currency: str,
100 amount: float, balance_after: float,
101 source_type: str, source_id: str = None,
102 description: str = ''):
103 txn = ResonanceTransaction(
104 user_id=user_id, currency=currency,
105 amount=amount, balance_after=balance_after,
106 source_type=source_type, source_id=source_id,
107 description=description,
108 )
109 db.add(txn)
111 @staticmethod
112 def award_pulse(db: Session, user_id: str, amount: int,
113 source_type: str, source_id: str = None,
114 description: str = '') -> int:
115 wallet = ResonanceService.get_or_create_wallet(db, user_id)
116 wallet.pulse += amount
117 wallet.season_pulse += amount
118 ResonanceService._log_transaction(
119 db, user_id, 'pulse', amount, wallet.pulse,
120 source_type, source_id, description)
121 return wallet.pulse
123 @staticmethod
124 def award_spark(db: Session, user_id: str, amount: int,
125 source_type: str, source_id: str = None,
126 description: str = '') -> int:
127 wallet = ResonanceService.get_or_create_wallet(db, user_id)
128 wallet.spark += amount
129 wallet.spark_lifetime += amount
130 wallet.season_spark += amount
131 ResonanceService._log_transaction(
132 db, user_id, 'spark', amount, wallet.spark,
133 source_type, source_id, description)
134 return wallet.spark
136 @staticmethod
137 def spend_spark(db: Session, user_id: str, amount: int,
138 source_type: str, source_id: str = None,
139 description: str = '') -> Tuple[bool, int]:
140 wallet = ResonanceService.get_or_create_wallet(db, user_id)
141 if wallet.spark < amount:
142 return False, wallet.spark
143 wallet.spark -= amount
144 ResonanceService._log_transaction(
145 db, user_id, 'spark', -amount, wallet.spark,
146 source_type, source_id, description)
147 return True, wallet.spark
149 @staticmethod
150 def award_signal(db: Session, user_id: str, amount: float,
151 source_type: str, source_id: str = None,
152 description: str = '') -> float:
153 wallet = ResonanceService.get_or_create_wallet(db, user_id)
154 wallet.signal += amount
155 wallet.signal_last_decay = datetime.utcnow()
156 ResonanceService._log_transaction(
157 db, user_id, 'signal', amount, wallet.signal,
158 source_type, source_id, description)
159 return wallet.signal
161 @staticmethod
162 def award_xp(db: Session, user_id: str, amount: int,
163 source_type: str, source_id: str = None,
164 description: str = '') -> Dict:
165 wallet = ResonanceService.get_or_create_wallet(db, user_id)
166 wallet.xp += amount
167 ResonanceService._log_transaction(
168 db, user_id, 'xp', amount, wallet.xp,
169 source_type, source_id, description)
170 leveled_up = ResonanceService._check_level_up(db, wallet)
171 return {
172 'xp': wallet.xp, 'level': wallet.level,
173 'level_title': wallet.level_title,
174 'leveled_up': leveled_up,
175 }
177 @staticmethod
178 def _check_level_up(db: Session, wallet: ResonanceWallet) -> bool:
179 leveled = False
180 while wallet.xp >= wallet.xp_next_level:
181 wallet.xp -= wallet.xp_next_level
182 wallet.level += 1
183 wallet.level_title = title_for_level(wallet.level)
184 wallet.xp_next_level = xp_for_level(wallet.level + 1)
185 leveled = True
186 if leveled:
187 # Sync level to user table
188 user = db.query(User).filter_by(id=wallet.user_id).first()
189 if user:
190 user.level = wallet.level
191 user.level_title = wallet.level_title
192 return leveled
194 @staticmethod
195 def award_action(db: Session, user_id: str, action: str,
196 source_id: str = None) -> Dict:
197 """Award all currencies for a known action type."""
198 awards = AWARD_TABLE.get(action, {})
199 if not awards:
200 return {}
202 result = {}
203 if 'pulse' in awards:
204 result['pulse'] = ResonanceService.award_pulse(
205 db, user_id, awards['pulse'], action, source_id,
206 f'Earned for {action}')
207 if 'spark' in awards:
208 result['spark'] = ResonanceService.award_spark(
209 db, user_id, awards['spark'], action, source_id,
210 f'Earned for {action}')
211 if 'signal' in awards:
212 result['signal'] = ResonanceService.award_signal(
213 db, user_id, awards['signal'], action, source_id,
214 f'Earned for {action}')
215 if 'xp' in awards:
216 xp_result = ResonanceService.award_xp(
217 db, user_id, awards['xp'], action, source_id,
218 f'Earned for {action}')
219 result.update(xp_result)
221 return result
223 @staticmethod
224 def award(db: Session, user_id: str, action: str, amount: int,
225 reason: str = '') -> Dict:
226 """Generic award entry point for action-based rewards.
228 Looks up *action* in AWARD_TABLE. When found the table values
229 are used (``amount`` is ignored); otherwise ``amount`` spark and
230 ``amount // 2`` xp are awarded as a sensible default.
231 """
232 awards = AWARD_TABLE.get(action)
233 if awards:
234 spark = awards.get('spark', 0)
235 xp = awards.get('xp', 0)
236 else:
237 spark = amount
238 xp = amount // 2
240 result = {}
241 desc = reason or f'Earned for {action}'
242 if spark:
243 result['spark'] = ResonanceService.award_spark(
244 db, user_id, spark, action, None, desc)
245 if xp:
246 xp_result = ResonanceService.award_xp(
247 db, user_id, xp, action, None, desc)
248 result.update(xp_result)
249 return result
251 @staticmethod
252 def process_streak(db: Session, user_id: str) -> Dict:
253 """Process daily login streak. Call once per day per user."""
254 wallet = ResonanceService.get_or_create_wallet(db, user_id)
255 today = datetime.utcnow().strftime('%Y-%m-%d')
257 if wallet.last_active_date == today:
258 return {'streak_days': wallet.streak_days, 'already_checked_in': True}
260 yesterday = (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d')
261 if wallet.last_active_date == yesterday:
262 wallet.streak_days += 1
263 else:
264 wallet.streak_days = 1
266 wallet.last_active_date = today
267 if wallet.streak_days > wallet.streak_best:
268 wallet.streak_best = wallet.streak_days
270 # Award streak spark: +streak_day
271 spark_bonus = wallet.streak_days
272 ResonanceService.award_spark(
273 db, user_id, spark_bonus, 'streak', None,
274 f'Day {wallet.streak_days} streak bonus')
276 # Award streak XP: +5 * streak_day
277 xp_bonus = 5 * wallet.streak_days
278 ResonanceService.award_xp(
279 db, user_id, xp_bonus, 'streak', None,
280 f'Day {wallet.streak_days} streak XP')
282 return {
283 'streak_days': wallet.streak_days,
284 'streak_best': wallet.streak_best,
285 'spark_bonus': spark_bonus,
286 'xp_bonus': xp_bonus,
287 'already_checked_in': False,
288 }
290 @staticmethod
291 def apply_signal_decay(db: Session) -> int:
292 """Batch job: apply 0.2%/day signal decay for users inactive >7 days."""
293 cutoff = datetime.utcnow() - timedelta(days=7)
294 wallets = db.query(ResonanceWallet).filter(
295 ResonanceWallet.signal > 0,
296 ResonanceWallet.signal_last_decay < cutoff
297 ).all()
299 decayed_count = 0
300 for w in wallets:
301 days_inactive = (datetime.utcnow() - (w.signal_last_decay or w.created_at)).days
302 days_to_decay = max(0, days_inactive - 7)
303 if days_to_decay > 0:
304 decay_rate = 0.002 * days_to_decay
305 decay_amount = w.signal * min(decay_rate, 0.5) # cap at 50%
306 w.signal = max(0, w.signal - decay_amount)
307 w.signal_last_decay = datetime.utcnow()
308 ResonanceService._log_transaction(
309 db, w.user_id, 'signal', -decay_amount, w.signal,
310 'decay', None, f'{days_to_decay}d inactivity decay')
311 decayed_count += 1
313 return decayed_count
315 @staticmethod
316 def get_leaderboard(db: Session, currency: str = 'pulse',
317 limit: int = 50, offset: int = 0,
318 region_id: str = None) -> List[Dict]:
319 """Get leaderboard sorted by currency."""
320 col_map = {
321 'pulse': ResonanceWallet.pulse,
322 'spark': ResonanceWallet.spark_lifetime,
323 'signal': ResonanceWallet.signal,
324 'xp': ResonanceWallet.xp,
325 'level': ResonanceWallet.level,
326 }
327 sort_col = col_map.get(currency, ResonanceWallet.pulse)
329 q = db.query(ResonanceWallet, User).join(
330 User, User.id == ResonanceWallet.user_id)
332 if region_id:
333 q = q.filter(User.region_id == region_id)
335 rows = q.order_by(desc(sort_col)).offset(offset).limit(limit).all()
337 result = []
338 for i, (wallet, user) in enumerate(rows, start=offset + 1):
339 entry = wallet.to_dict()
340 entry['rank'] = i
341 entry['username'] = user.username
342 entry['display_name'] = user.display_name
343 entry['avatar_url'] = user.avatar_url
344 entry['user_type'] = user.user_type
345 result.append(entry)
347 return result
349 @staticmethod
350 def get_transactions(db: Session, user_id: str,
351 currency: str = None,
352 limit: int = 50, offset: int = 0) -> List[Dict]:
353 """Get transaction history for a user."""
354 q = db.query(ResonanceTransaction).filter_by(user_id=user_id)
355 if currency:
356 q = q.filter_by(currency=currency)
357 txns = q.order_by(desc(ResonanceTransaction.created_at)
358 ).offset(offset).limit(limit).all()
359 return [t.to_dict() for t in txns]
361 @staticmethod
362 def get_breakdown(db: Session, user_id: str) -> Dict:
363 """Get detailed breakdown of a user's resonance."""
364 wallet = ResonanceService.get_wallet(db, user_id)
365 if not wallet:
366 return {}
368 # Count transactions by source_type
369 source_counts = db.query(
370 ResonanceTransaction.source_type,
371 func.count(ResonanceTransaction.id),
372 func.sum(ResonanceTransaction.amount)
373 ).filter_by(user_id=user_id).group_by(
374 ResonanceTransaction.source_type).all()
376 sources = {}
377 for source_type, count, total in source_counts:
378 sources[source_type] = {'count': count, 'total': float(total or 0)}
380 wallet['sources'] = sources
381 return wallet