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

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 

7 

8from .auth import require_auth 

9from .rate_limiter import rate_limit 

10from .models import get_db 

11from .game_service import GameService 

12 

13logger = logging.getLogger('hevolve_social') 

14 

15games_bp = Blueprint('games', __name__, url_prefix='/api/social') 

16 

17 

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 

25 

26 

27def _err(msg, status=400): 

28 return jsonify({'success': False, 'error': msg}), status 

29 

30 

31def _get_json(): 

32 return request.get_json(force=True, silent=True) or {} 

33 

34 

35# ═══════════════════════════════════════════════════════════════ 

36# GAME CATALOG 

37# ═══════════════════════════════════════════════════════════════ 

38 

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 

44 

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) 

52 

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) 

74 

75 

76# ═══════════════════════════════════════════════════════════════ 

77# GAME SESSIONS (12 endpoints) 

78# ═══════════════════════════════════════════════════════════════ 

79 

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

93 

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

112 

113 

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) 

121 

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

134 

135 

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

151 

152 

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

162 

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 

175 

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

185 

186 

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

204 

205 

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

214 

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 

226 

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

236 

237 

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

248 

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 

263 

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

273 

274 

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

280 

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. 

285 

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

291 

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. 

299 

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. 

307 

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) 

313 

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

322 

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) 

333 

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

353 

354 

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

372 

373 

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

391 

392 

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

399 

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

409 

410 

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

418 

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

432 

433 

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

442 

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

448 

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 

465 

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

475 

476 

477# ═══════════════════════════════════════════════════════════════ 

478# COMPUTE LENDING (6 endpoints) 

479# ═══════════════════════════════════════════════════════════════ 

480 

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) 

491 

492 already_opted = user.idle_compute_opt_in 

493 user.idle_compute_opt_in = True 

494 db.flush() 

495 

496 if not already_opted: 

497 ResonanceService.award_action(db, g.user_id, 'compute_opt_in') 

498 

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 

505 

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

514 

515 

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

534 

535 

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) 

546 

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

563 

564 

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

578 

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

584 

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

599 

600 

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

616 

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

629 

630 

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

652 

653 

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