Coverage for integrations / social / api_games.py: 26.3%
312 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 - Multiplayer Games API Blueprint
312 endpoints for game session lifecycle, matchmaking, and history.
4"""
5import logging
6from flask import Blueprint, request, jsonify, g
8from .auth import require_auth
9from .rate_limiter import rate_limit
10from .models import get_db
11from .game_service import GameService
13logger = logging.getLogger('hevolve_social')
15games_bp = Blueprint('games', __name__, url_prefix='/api/social')
18def _ok(data=None, meta=None, status=200):
19 r = {'success': True}
20 if data is not None:
21 r['data'] = data
22 if meta is not None:
23 r['meta'] = meta
24 return jsonify(r), status
27def _err(msg, status=400):
28 return jsonify({'success': False, 'error': msg}), status
31def _get_json():
32 return request.get_json(force=True, silent=True) or {}
35# ═══════════════════════════════════════════════════════════════
36# GAME CATALOG
37# ═══════════════════════════════════════════════════════════════
39@games_bp.route('/games/catalog', methods=['GET'])
40@require_auth
41def game_catalog():
42 """Browse the full game catalog with filters."""
43 from .game_catalog import list_catalog, get_catalog_entry
45 # Single game lookup
46 game_id = request.args.get('id')
47 if game_id:
48 entry = get_catalog_entry(game_id)
49 if not entry:
50 return _err("Game not found in catalog", 404)
51 return _ok(entry)
53 # Filtered list
54 try:
55 result = list_catalog(
56 audience=request.args.get('audience'),
57 category=request.args.get('category'),
58 multiplayer=request.args.get('multiplayer', type=lambda v: v.lower() == 'true')
59 if 'multiplayer' in request.args else None,
60 featured=request.args.get('featured', type=lambda v: v.lower() == 'true')
61 if 'featured' in request.args else None,
62 tag=request.args.get('tag'),
63 search=request.args.get('search'),
64 limit=min(int(request.args.get('limit', 50)), 200),
65 offset=int(request.args.get('offset', 0)),
66 )
67 return _ok(result['items'], meta={
68 'total': result['total'],
69 'categories': result['categories'],
70 })
71 except Exception as e:
72 logger.exception("Error fetching game catalog")
73 return _err("Failed to fetch catalog", 500)
76# ═══════════════════════════════════════════════════════════════
77# GAME SESSIONS (12 endpoints)
78# ═══════════════════════════════════════════════════════════════
80@games_bp.route('/games', methods=['POST'])
81@require_auth
82@rate_limit(30)
83def create_game():
84 """Create a new game session. Host auto-joins."""
85 data = _get_json()
86 game_type = data.get('game_type', 'trivia')
87 config = data.get('config', {})
88 max_players = data.get('max_players', 4)
89 total_rounds = data.get('total_rounds', 5)
90 encounter_id = data.get('encounter_id')
91 community_id = data.get('community_id')
92 challenge_id = data.get('challenge_id')
94 db = get_db()
95 try:
96 session = GameService.create_session(
97 db, host_user_id=g.user_id, game_type=game_type,
98 config=config, encounter_id=encounter_id,
99 community_id=community_id, challenge_id=challenge_id,
100 max_players=max_players, total_rounds=total_rounds,
101 )
102 db.commit()
103 return _ok(session, status=201)
104 except ValueError as e:
105 return _err(str(e))
106 except Exception as e:
107 db.rollback()
108 logger.exception("Error creating game")
109 return _err("Failed to create game", 500)
110 finally:
111 db.close()
114@games_bp.route('/games', methods=['GET'])
115@require_auth
116def list_games():
117 """List open/joinable game sessions."""
118 game_type = request.args.get('game_type')
119 community_id = request.args.get('community_id')
120 limit = min(int(request.args.get('limit', 20)), 50)
122 db = get_db()
123 try:
124 sessions = GameService.find_open_sessions(
125 db, g.user_id, game_type=game_type,
126 community_id=community_id, limit=limit,
127 )
128 return _ok(sessions)
129 except Exception as e:
130 logger.exception("Error listing games")
131 return _err("Failed to list games", 500)
132 finally:
133 db.close()
136@games_bp.route('/games/<session_id>', methods=['GET'])
137@require_auth
138def get_game(session_id):
139 """Get game session state."""
140 db = get_db()
141 try:
142 session = GameService.get_session(db, session_id)
143 if not session:
144 return _err("Game not found", 404)
145 return _ok(session)
146 except Exception as e:
147 logger.exception("Error getting game")
148 return _err("Failed to get game", 500)
149 finally:
150 db.close()
153@games_bp.route('/games/<session_id>/join', methods=['POST'])
154@require_auth
155@rate_limit(30)
156def join_game(session_id):
157 """Join a waiting game session."""
158 db = get_db()
159 try:
160 session = GameService.join_session(db, session_id, g.user_id)
161 db.commit()
163 # Notify other players
164 try:
165 from .realtime import on_notification
166 for p in session.get('participants', []):
167 if p['user_id'] != g.user_id:
168 on_notification(p['user_id'], {
169 'type': 'game_player_joined',
170 'game_id': session_id,
171 'user_id': g.user_id,
172 })
173 except Exception:
174 pass
176 return _ok(session)
177 except ValueError as e:
178 return _err(str(e))
179 except Exception as e:
180 db.rollback()
181 logger.exception("Error joining game")
182 return _err("Failed to join game", 500)
183 finally:
184 db.close()
187@games_bp.route('/games/<session_id>/ready', methods=['POST'])
188@require_auth
189def ready_game(session_id):
190 """Mark yourself as ready."""
191 db = get_db()
192 try:
193 session = GameService.set_ready(db, session_id, g.user_id)
194 db.commit()
195 return _ok(session)
196 except ValueError as e:
197 return _err(str(e))
198 except Exception as e:
199 db.rollback()
200 logger.exception("Error marking ready")
201 return _err("Failed to mark ready", 500)
202 finally:
203 db.close()
206@games_bp.route('/games/<session_id>/start', methods=['POST'])
207@require_auth
208def start_game(session_id):
209 """Host starts the game."""
210 db = get_db()
211 try:
212 session = GameService.start_session(db, session_id, g.user_id)
213 db.commit()
215 # Notify all players game started
216 try:
217 from .realtime import on_notification
218 for p in session.get('participants', []):
219 on_notification(p['user_id'], {
220 'type': 'game_started',
221 'game_id': session_id,
222 'game_type': session.get('game_type'),
223 })
224 except Exception:
225 pass
227 return _ok(session)
228 except ValueError as e:
229 return _err(str(e))
230 except Exception as e:
231 db.rollback()
232 logger.exception("Error starting game")
233 return _err("Failed to start game", 500)
234 finally:
235 db.close()
238@games_bp.route('/games/<session_id>/move', methods=['POST'])
239@require_auth
240@rate_limit(60)
241def submit_move(session_id):
242 """Submit a move in an active game."""
243 data = _get_json()
244 db = get_db()
245 try:
246 session = GameService.submit_move(db, session_id, g.user_id, data)
247 db.commit()
249 # If game completed, notify all players
250 if session.get('status') == 'completed':
251 try:
252 from .realtime import on_notification
253 for p in session.get('participants', []):
254 on_notification(p['user_id'], {
255 'type': 'game_completed',
256 'game_id': session_id,
257 'result': p.get('result'),
258 'spark_earned': p.get('spark_earned', 0),
259 'xp_earned': p.get('xp_earned', 0),
260 })
261 except Exception:
262 pass
264 return _ok(session)
265 except ValueError as e:
266 return _err(str(e))
267 except Exception as e:
268 db.rollback()
269 logger.exception("Error submitting move")
270 return _err("Failed to submit move", 500)
271 finally:
272 db.close()
275@games_bp.route('/games/<session_id>/ai_move', methods=['POST'])
276@require_auth
277@rate_limit('games_ai_move') # per-session chatty polling allowed
278def ai_move(session_id):
279 """Generate a server-side AI move for a solo session (Phase 1).
281 The endpoint is read-only against the session — it returns a move
282 dict ready to feed into POST /games/<id>/move. The client does the
283 two-step dance (get-ai-move → submit-move) so the AI's turn is
284 observable in the normal move history.
286 Only the host user may request AI moves (prevents griefing another
287 player's turn). Board-game AI (boardgame engine family) is
288 client-authoritative — this endpoint returns an error for those
289 game types and the client falls back to local boardgame.io
290 MCTSBot. See landing-page/src/utils/gameAI.js (Nunba).
292 PHASE 1 LIMITATION — "host plays both sides":
293 The returned move, when submitted via POST /move, is authenticated
294 as g.user_id (the host) and credited to the host's score. There is
295 no distinct AI GameParticipant yet. Until Phase 2 adds one + a
296 submit_move_as_ai path, callers should treat this endpoint as a
297 "move hint" helper, NOT a true AI opponent. See game_ai.py module
298 docstring for the full Phase 2 plan.
300 TURN-OWNERSHIP NOTE:
301 The endpoint does NOT verify that it is currently the AI's turn
302 (there is no AI turn yet). For games where multiple players can
303 answer per round (trivia) this is inconsequential. For turn-based
304 engines (word_chain etc.) the caller is responsible for calling
305 at the correct moment. Future Phase 2 work will add a turn check
306 once AI participants exist.
308 Request JSON:
309 difficulty: 'easy' | 'medium' | 'hard' (default 'medium')
310 ai_user_id: str (default 'ai')
311 (cosmetic only in Phase 1 — submission still uses
312 the authenticated host user_id)
314 Response JSON:
315 move: {...} — shape depends on engine
316 ai_user_id: str
317 difficulty: str
318 """
319 data = _get_json()
320 difficulty = data.get('difficulty', 'medium')
321 ai_user_id = data.get('ai_user_id', 'ai')
323 from .models import GameSession
324 db = get_db()
325 try:
326 session = db.query(GameSession).filter_by(id=session_id).first()
327 if not session:
328 return _err("Game session not found", 404)
329 if session.status != 'active':
330 return _err(f"Session not active (status: {session.status})")
331 if session.host_user_id != g.user_id:
332 return _err("Only the host can request AI moves", 403)
334 from .game_ai import generate_ai_move
335 move = generate_ai_move(
336 game_state=session.game_state,
337 game_type=session.game_type,
338 ai_user_id=ai_user_id,
339 difficulty=difficulty,
340 )
341 return _ok({
342 'move': move,
343 'ai_user_id': ai_user_id,
344 'difficulty': difficulty,
345 })
346 except ValueError as e:
347 return _err(str(e))
348 except Exception:
349 logger.exception("Error generating AI move")
350 return _err("Failed to generate AI move", 500)
351 finally:
352 db.close()
355@games_bp.route('/games/<session_id>/leave', methods=['POST'])
356@require_auth
357def leave_game(session_id):
358 """Leave a game session."""
359 db = get_db()
360 try:
361 session = GameService.leave_session(db, session_id, g.user_id)
362 db.commit()
363 return _ok(session)
364 except ValueError as e:
365 return _err(str(e))
366 except Exception as e:
367 db.rollback()
368 logger.exception("Error leaving game")
369 return _err("Failed to leave game", 500)
370 finally:
371 db.close()
374@games_bp.route('/games/<session_id>/results', methods=['GET'])
375@require_auth
376def game_results(session_id):
377 """Get final game results."""
378 db = get_db()
379 try:
380 session = GameService.get_session(db, session_id)
381 if not session:
382 return _err("Game not found", 404)
383 if session.get('status') != 'completed':
384 return _err("Game not yet completed")
385 return _ok(session)
386 except Exception as e:
387 logger.exception("Error getting results")
388 return _err("Failed to get results", 500)
389 finally:
390 db.close()
393@games_bp.route('/games/history', methods=['GET'])
394@require_auth
395def game_history():
396 """Get user's game history."""
397 limit = min(int(request.args.get('limit', 20)), 50)
398 offset = int(request.args.get('offset', 0))
400 db = get_db()
401 try:
402 history = GameService.get_history(db, g.user_id, limit=limit, offset=offset)
403 return _ok(history)
404 except Exception as e:
405 logger.exception("Error getting game history")
406 return _err("Failed to get history", 500)
407 finally:
408 db.close()
411@games_bp.route('/games/quick-match', methods=['POST'])
412@require_auth
413@rate_limit(10)
414def quick_match():
415 """Auto-matchmake: join an open session or create a new one."""
416 data = _get_json()
417 game_type = data.get('game_type', 'trivia')
419 db = get_db()
420 try:
421 session = GameService.quick_match(db, g.user_id, game_type=game_type)
422 db.commit()
423 return _ok(session)
424 except ValueError as e:
425 return _err(str(e))
426 except Exception as e:
427 db.rollback()
428 logger.exception("Error in quick match")
429 return _err("Failed to quick match", 500)
430 finally:
431 db.close()
434@games_bp.route('/games/from-encounter/<encounter_id>', methods=['POST'])
435@require_auth
436@rate_limit(10)
437def game_from_encounter(encounter_id):
438 """Create a game from an existing encounter (play with a bond)."""
439 data = _get_json()
440 game_type = data.get('game_type', 'trivia')
441 config = data.get('config', {})
443 db = get_db()
444 try:
445 session = GameService.create_from_encounter(
446 db, encounter_id, g.user_id, game_type, config)
447 db.commit()
449 # Notify the other person in the encounter
450 try:
451 from .models import Encounter
452 enc = db.query(Encounter).filter_by(id=encounter_id).first()
453 if enc:
454 other_id = enc.user_b_id if enc.user_a_id == g.user_id else enc.user_a_id
455 from .realtime import on_notification
456 on_notification(other_id, {
457 'type': 'game_invite',
458 'game_id': session['id'],
459 'game_type': game_type,
460 'from_user_id': g.user_id,
461 'encounter_id': encounter_id,
462 })
463 except Exception:
464 pass
466 return _ok(session, status=201)
467 except ValueError as e:
468 return _err(str(e))
469 except Exception as e:
470 db.rollback()
471 logger.exception("Error creating game from encounter")
472 return _err("Failed to create game", 500)
473 finally:
474 db.close()
477# ═══════════════════════════════════════════════════════════════
478# COMPUTE LENDING (6 endpoints)
479# ═══════════════════════════════════════════════════════════════
481@games_bp.route('/compute/opt-in', methods=['POST'])
482@require_auth
483@rate_limit(5)
484def compute_opt_in():
485 """Enable idle compute sharing."""
486 db = get_db()
487 try:
488 user = db.query(User).filter_by(id=g.user_id).first()
489 if not user:
490 return _err("User not found", 404)
492 already_opted = user.idle_compute_opt_in
493 user.idle_compute_opt_in = True
494 db.flush()
496 if not already_opted:
497 ResonanceService.award_action(db, g.user_id, 'compute_opt_in')
499 # Check for first_compute_share achievement
500 try:
501 from .gamification_service import GamificationService
502 GamificationService.check_achievements(db, g.user_id)
503 except Exception:
504 pass
506 db.commit()
507 return _ok({'opted_in': True, 'first_time': not already_opted})
508 except Exception as e:
509 db.rollback()
510 logger.exception("Error opting in to compute")
511 return _err("Failed to opt in", 500)
512 finally:
513 db.close()
516@games_bp.route('/compute/opt-out', methods=['POST'])
517@require_auth
518def compute_opt_out():
519 """Disable idle compute sharing."""
520 db = get_db()
521 try:
522 user = db.query(User).filter_by(id=g.user_id).first()
523 if not user:
524 return _err("User not found", 404)
525 user.idle_compute_opt_in = False
526 db.commit()
527 return _ok({'opted_in': False})
528 except Exception as e:
529 db.rollback()
530 logger.exception("Error opting out of compute")
531 return _err("Failed to opt out", 500)
532 finally:
533 db.close()
536@games_bp.route('/compute/status', methods=['GET'])
537@require_auth
538def compute_status():
539 """Get compute sharing status."""
540 from .models import User, PeerNode
541 db = get_db()
542 try:
543 user = db.query(User).filter_by(id=g.user_id).first()
544 if not user:
545 return _err("User not found", 404)
547 node = db.query(PeerNode).filter_by(node_operator_id=g.user_id).first()
548 status = {
549 'opted_in': bool(user.idle_compute_opt_in),
550 'node_active': bool(node and node.status == 'active') if node else False,
551 'contribution_score': node.contribution_score if node else 0,
552 'visibility_tier': node.visibility_tier if node else 'standard',
553 'gpu_hours_served': node.gpu_hours_served if node else 0,
554 'total_inferences': node.total_inferences if node else 0,
555 'energy_kwh': node.energy_kwh_contributed if node else 0,
556 }
557 return _ok(status)
558 except Exception as e:
559 logger.exception("Error getting compute status")
560 return _err("Failed to get status", 500)
561 finally:
562 db.close()
565@games_bp.route('/compute/impact', methods=['GET'])
566@require_auth
567def compute_impact():
568 """Get personal compute impact stats."""
569 from .models import PeerNode, HostingReward
570 db = get_db()
571 try:
572 node = db.query(PeerNode).filter_by(node_operator_id=g.user_id).first()
573 if not node:
574 return _ok({
575 'gpu_hours': 0, 'inferences': 0, 'energy_kwh': 0,
576 'spark_earned': 0, 'users_helped': 0,
577 })
579 # Sum Spark earned from hosting rewards
580 from sqlalchemy import func
581 total_spark = db.query(func.coalesce(func.sum(HostingReward.spark_amount), 0)).filter_by(
582 node_operator_id=g.user_id
583 ).scalar()
585 return _ok({
586 'gpu_hours': node.gpu_hours_served or 0,
587 'inferences': node.total_inferences or 0,
588 'energy_kwh': node.energy_kwh_contributed or 0,
589 'spark_earned': total_spark,
590 'agent_count': node.agent_count or 0,
591 'contribution_score': node.contribution_score or 0,
592 'visibility_tier': node.visibility_tier or 'standard',
593 })
594 except Exception as e:
595 logger.exception("Error getting compute impact")
596 return _err("Failed to get impact", 500)
597 finally:
598 db.close()
601@games_bp.route('/compute/community-impact', methods=['GET'])
602@require_auth
603def compute_community_impact():
604 """Get aggregate community compute stats."""
605 from .models import PeerNode
606 from sqlalchemy import func
607 db = get_db()
608 try:
609 stats = db.query(
610 func.count(PeerNode.id).label('total_nodes'),
611 func.coalesce(func.sum(PeerNode.gpu_hours_served), 0).label('total_gpu_hours'),
612 func.coalesce(func.sum(PeerNode.total_inferences), 0).label('total_inferences'),
613 func.coalesce(func.sum(PeerNode.energy_kwh_contributed), 0).label('total_energy'),
614 func.coalesce(func.sum(PeerNode.agent_count), 0).label('total_agents'),
615 ).filter(PeerNode.status == 'active').first()
617 return _ok({
618 'active_nodes': stats.total_nodes or 0,
619 'total_gpu_hours': float(stats.total_gpu_hours or 0),
620 'total_inferences': int(stats.total_inferences or 0),
621 'total_energy_kwh': float(stats.total_energy or 0),
622 'total_agents_hosted': int(stats.total_agents or 0),
623 })
624 except Exception as e:
625 logger.exception("Error getting community impact")
626 return _err("Failed to get community impact", 500)
627 finally:
628 db.close()
631@games_bp.route('/compute/health-check', methods=['POST'])
632@require_auth
633@rate_limit(60)
634def compute_health_check():
635 """Client heartbeat — updates PeerNode.last_seen."""
636 from .models import PeerNode
637 db = get_db()
638 try:
639 node = db.query(PeerNode).filter_by(node_operator_id=g.user_id).first()
640 if node:
641 node.last_seen = datetime.utcnow()
642 node.status = 'active'
643 db.commit()
644 return _ok({'status': 'active'})
645 return _ok({'status': 'no_node'})
646 except Exception as e:
647 db.rollback()
648 logger.exception("Error in compute health check")
649 return _err("Failed to update health", 500)
650 finally:
651 db.close()
654# Need datetime for health check
655from datetime import datetime
656# Need User for compute endpoints
657from .models import User
658from .resonance_engine import ResonanceService