Coverage for integrations / social / game_service.py: 18.5%

216 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2HevolveSocial - Game Service 

3Multiplayer game session management: create, join, play, complete. 

4Ties into Resonance (rewards), Encounters (bonds), and Gamification (achievements). 

5""" 

6import logging 

7from datetime import datetime, timedelta 

8from typing import Optional, Dict, List 

9 

10from sqlalchemy import desc, func, or_ 

11from sqlalchemy.orm import Session 

12 

13from .models import GameSession, GameParticipant, User 

14from .resonance_engine import ResonanceService 

15from .encounter_service import EncounterService 

16 

17logger = logging.getLogger('hevolve_social') 

18 

19# ─── Constants ─── 

20 

21VALID_STATUSES = ('waiting', 'active', 'completed', 'expired', 'cancelled') 

22DEFAULT_EXPIRY_MINUTES = 30 

23MIN_PLAYERS = 2 

24MAX_PLAYERS_CAP = 8 

25 

26 

27class GameService: 

28 

29 # ─── Session Lifecycle ─── 

30 

31 @staticmethod 

32 def create_session(db: Session, host_user_id: str, game_type: str, 

33 config: Optional[Dict] = None, 

34 encounter_id: str = None, 

35 community_id: str = None, 

36 challenge_id: str = None, 

37 max_players: int = 4, 

38 total_rounds: int = 5, 

39 expiry_minutes: int = DEFAULT_EXPIRY_MINUTES) -> Dict: 

40 """Create a new game session in 'waiting' state. Host auto-joins.""" 

41 from .game_types import is_valid_game_type 

42 if not is_valid_game_type(game_type): 

43 raise ValueError(f"Invalid game_type '{game_type}'") 

44 

45 # Resolve catalog entry → merge engine_config into session config 

46 resolved_type = game_type 

47 merged_config = config or {} 

48 try: 

49 from .game_catalog import get_catalog_entry, get_config_for_catalog_entry 

50 catalog_entry = get_catalog_entry(game_type) 

51 if catalog_entry: 

52 resolved_type = catalog_entry['engine'] 

53 merged_config = get_config_for_catalog_entry(game_type, config) 

54 # Use catalog defaults if not specified 

55 if total_rounds == 5 and catalog_entry.get('default_rounds'): 

56 total_rounds = catalog_entry['default_rounds'] 

57 if max_players == 4 and catalog_entry.get('max_players'): 

58 max_players = catalog_entry['max_players'] 

59 except ImportError: 

60 pass 

61 

62 max_players = min(max(MIN_PLAYERS, max_players), MAX_PLAYERS_CAP) 

63 

64 session = GameSession( 

65 game_type=resolved_type, 

66 host_user_id=host_user_id, 

67 encounter_id=encounter_id, 

68 community_id=community_id, 

69 challenge_id=challenge_id, 

70 max_players=max_players, 

71 total_rounds=total_rounds, 

72 config=merged_config, 

73 game_state={'round_data': [], 'moves': [], 

74 'catalog_id': game_type if game_type != resolved_type else None}, 

75 expires_at=datetime.utcnow() + timedelta(minutes=expiry_minutes), 

76 ) 

77 db.add(session) 

78 db.flush() 

79 

80 # Host auto-joins 

81 participant = GameParticipant( 

82 game_session_id=session.id, 

83 user_id=host_user_id, 

84 is_ready=True, 

85 ) 

86 db.add(participant) 

87 db.flush() 

88 

89 logger.info("Game session created: %s (%s) by %s", session.id, game_type, host_user_id) 

90 return session.to_dict() 

91 

92 @staticmethod 

93 def join_session(db: Session, session_id: str, user_id: str) -> Dict: 

94 """Join an existing waiting game session.""" 

95 session = db.query(GameSession).filter_by(id=session_id).first() 

96 if not session: 

97 raise ValueError("Game session not found") 

98 if session.status != 'waiting': 

99 raise ValueError(f"Cannot join — session is '{session.status}'") 

100 if len(session.participants) >= session.max_players: 

101 raise ValueError("Game is full") 

102 

103 existing = db.query(GameParticipant).filter_by( 

104 game_session_id=session_id, user_id=user_id 

105 ).first() 

106 if existing: 

107 raise ValueError("Already in this game") 

108 

109 participant = GameParticipant( 

110 game_session_id=session_id, 

111 user_id=user_id, 

112 ) 

113 db.add(participant) 

114 db.flush() 

115 

116 logger.info("User %s joined game %s", user_id, session_id) 

117 return session.to_dict() 

118 

119 @staticmethod 

120 def set_ready(db: Session, session_id: str, user_id: str) -> Dict: 

121 """Mark a participant as ready.""" 

122 participant = db.query(GameParticipant).filter_by( 

123 game_session_id=session_id, user_id=user_id 

124 ).first() 

125 if not participant: 

126 raise ValueError("Not in this game") 

127 participant.is_ready = True 

128 db.flush() 

129 session = db.query(GameSession).filter_by(id=session_id).first() 

130 return session.to_dict() 

131 

132 @staticmethod 

133 def start_session(db: Session, session_id: str, host_user_id: str) -> Dict: 

134 """Host starts the game. All players must be ready, minimum 2 players.""" 

135 session = db.query(GameSession).filter_by(id=session_id).first() 

136 if not session: 

137 raise ValueError("Game session not found") 

138 if session.host_user_id != host_user_id: 

139 raise ValueError("Only the host can start the game") 

140 if session.status != 'waiting': 

141 raise ValueError(f"Cannot start — session is '{session.status}'") 

142 if len(session.participants) < MIN_PLAYERS: 

143 raise ValueError(f"Need at least {MIN_PLAYERS} players to start") 

144 not_ready = [p for p in session.participants if not p.is_ready] 

145 if not_ready: 

146 raise ValueError(f"{len(not_ready)} player(s) not ready") 

147 

148 session.status = 'active' 

149 session.started_at = datetime.utcnow() 

150 session.current_round = 1 

151 

152 # Initialize game state via game type handler 

153 from .game_types import get_game_type 

154 handler = get_game_type(session.game_type) 

155 session.game_state = handler.initialize(session.config, session.total_rounds, 

156 [p.user_id for p in session.participants]) 

157 db.flush() 

158 

159 logger.info("Game %s started (%s, %d players)", 

160 session_id, session.game_type, len(session.participants)) 

161 return session.to_dict() 

162 

163 @staticmethod 

164 def submit_move(db: Session, session_id: str, user_id: str, 

165 move_data: Dict) -> Dict: 

166 """Submit a move in an active game. Delegates to game type handler.""" 

167 session = db.query(GameSession).filter_by(id=session_id).first() 

168 if not session: 

169 raise ValueError("Game session not found") 

170 if session.status != 'active': 

171 raise ValueError(f"Cannot move — session is '{session.status}'") 

172 

173 participant = db.query(GameParticipant).filter_by( 

174 game_session_id=session_id, user_id=user_id 

175 ).first() 

176 if not participant: 

177 raise ValueError("Not in this game") 

178 

179 from .game_types import get_game_type 

180 handler = get_game_type(session.game_type) 

181 

182 # Validate move 

183 valid, reason = handler.validate_move(session.game_state, user_id, move_data) 

184 if not valid: 

185 raise ValueError(f"Invalid move: {reason}") 

186 

187 # Apply move 

188 new_state, score_delta = handler.apply_move( 

189 session.game_state, user_id, move_data) 

190 session.game_state = new_state 

191 participant.score += score_delta 

192 

193 # Check round/game end 

194 if handler.check_round_end(session.game_state): 

195 session.current_round += 1 

196 if handler.check_game_end(session.game_state, session.current_round, session.total_rounds): 

197 return GameService._complete_session(db, session) 

198 

199 db.flush() 

200 return session.to_dict() 

201 

202 @staticmethod 

203 def _complete_session(db: Session, session: GameSession) -> Dict: 

204 """Finalize game: calculate results, award Resonance, record encounters.""" 

205 from .game_types import get_game_type 

206 handler = get_game_type(session.game_type) 

207 

208 results = handler.calculate_results(session.game_state, session.participants) 

209 

210 session.status = 'completed' 

211 session.ended_at = datetime.utcnow() 

212 

213 for participant in session.participants: 

214 result = results.get(participant.user_id, {}) 

215 participant.result = result.get('result', 'draw') 

216 participant.finished_at = datetime.utcnow() 

217 

218 # Award Resonance based on result 

219 if participant.result == 'win': 

220 award = ResonanceService.award_action( 

221 db, participant.user_id, 'game_win', source_id=session.id) 

222 else: 

223 award = ResonanceService.award_action( 

224 db, participant.user_id, 'game_participate', source_id=session.id) 

225 

226 if award: 

227 participant.spark_earned = award.get('spark', 0) 

228 participant.xp_earned = award.get('xp', 0) 

229 

230 # Record encounters between all participant pairs 

231 user_ids = [p.user_id for p in session.participants] 

232 for i, uid_a in enumerate(user_ids): 

233 for uid_b in user_ids[i + 1:]: 

234 EncounterService.record_encounter( 

235 db, uid_a, uid_b, 

236 context_type='game', 

237 context_id=session.id, 

238 ) 

239 # Also award multiplayer_encounter 

240 ResonanceService.award_action( 

241 db, uid_a, 'multiplayer_encounter', source_id=session.id) 

242 ResonanceService.award_action( 

243 db, uid_b, 'multiplayer_encounter', source_id=session.id) 

244 

245 db.flush() 

246 

247 logger.info("Game %s completed (%s). Results: %s", 

248 session.id, session.game_type, 

249 {p.user_id[:8]: p.result for p in session.participants}) 

250 return session.to_dict() 

251 

252 @staticmethod 

253 def leave_session(db: Session, session_id: str, user_id: str) -> Dict: 

254 """Leave a game. If host leaves a waiting game, cancel it.""" 

255 session = db.query(GameSession).filter_by(id=session_id).first() 

256 if not session: 

257 raise ValueError("Game session not found") 

258 

259 participant = db.query(GameParticipant).filter_by( 

260 game_session_id=session_id, user_id=user_id 

261 ).first() 

262 if not participant: 

263 raise ValueError("Not in this game") 

264 

265 if session.status == 'waiting': 

266 if session.host_user_id == user_id: 

267 session.status = 'cancelled' 

268 session.ended_at = datetime.utcnow() 

269 else: 

270 db.delete(participant) 

271 elif session.status == 'active': 

272 participant.result = 'abandoned' 

273 participant.finished_at = datetime.utcnow() 

274 # If only 1 player left, auto-complete 

275 active_players = [p for p in session.participants 

276 if p.result != 'abandoned' and p.user_id != user_id] 

277 if len(active_players) < MIN_PLAYERS: 

278 for p in active_players: 

279 p.result = 'win' 

280 p.finished_at = datetime.utcnow() 

281 ResonanceService.award_action( 

282 db, p.user_id, 'game_win', source_id=session.id) 

283 session.status = 'completed' 

284 session.ended_at = datetime.utcnow() 

285 

286 db.flush() 

287 return session.to_dict() 

288 

289 # ─── Discovery & History ─── 

290 

291 @staticmethod 

292 def find_open_sessions(db: Session, user_id: str, 

293 game_type: str = None, 

294 community_id: str = None, 

295 limit: int = 20) -> List[Dict]: 

296 """List joinable game sessions (waiting, not full, not expired).""" 

297 query = db.query(GameSession).filter( 

298 GameSession.status == 'waiting', 

299 GameSession.expires_at > datetime.utcnow(), 

300 ) 

301 if game_type: 

302 query = query.filter(GameSession.game_type == game_type) 

303 if community_id: 

304 query = query.filter(GameSession.community_id == community_id) 

305 

306 sessions = query.order_by(desc(GameSession.created_at)).limit(limit).all() 

307 

308 # Filter out full sessions and sessions user is already in 

309 result = [] 

310 for s in sessions: 

311 if len(s.participants) < s.max_players: 

312 already_in = any(p.user_id == user_id for p in s.participants) 

313 d = s.to_dict() 

314 d['already_joined'] = already_in 

315 result.append(d) 

316 return result 

317 

318 @staticmethod 

319 def get_session(db: Session, session_id: str) -> Optional[Dict]: 

320 """Get game session by ID.""" 

321 session = db.query(GameSession).filter_by(id=session_id).first() 

322 return session.to_dict() if session else None 

323 

324 @staticmethod 

325 def get_history(db: Session, user_id: str, 

326 limit: int = 20, offset: int = 0) -> List[Dict]: 

327 """Get user's game history (completed/cancelled games).""" 

328 sessions = db.query(GameSession).join(GameParticipant).filter( 

329 GameParticipant.user_id == user_id, 

330 GameSession.status.in_(['completed', 'cancelled']), 

331 ).order_by(desc(GameSession.ended_at)).offset(offset).limit(limit).all() 

332 return [s.to_dict() for s in sessions] 

333 

334 @staticmethod 

335 def create_from_encounter(db: Session, encounter_id: str, user_id: str, 

336 game_type: str, config: Optional[Dict] = None) -> Dict: 

337 """Create a game session linked to an existing encounter.""" 

338 return GameService.create_session( 

339 db, host_user_id=user_id, game_type=game_type, 

340 config=config, encounter_id=encounter_id, 

341 max_players=2, total_rounds=3, 

342 expiry_minutes=15, 

343 ) 

344 

345 @staticmethod 

346 def quick_match(db: Session, user_id: str, 

347 game_type: str = 'trivia') -> Dict: 

348 """Auto-matchmake: join an open session or create a new one.""" 

349 # Resolve catalog ID to engine name for open session search 

350 search_type = game_type 

351 try: 

352 from .game_catalog import get_catalog_entry 

353 entry = get_catalog_entry(game_type) 

354 if entry: 

355 search_type = entry['engine'] 

356 except ImportError: 

357 pass 

358 

359 open_sessions = GameService.find_open_sessions( 

360 db, user_id, game_type=search_type, limit=5) 

361 # Prefer sessions we haven't joined yet 

362 for s in open_sessions: 

363 if not s.get('already_joined'): 

364 return GameService.join_session(db, s['id'], user_id) 

365 # No open sessions — create one 

366 return GameService.create_session(db, user_id, game_type) 

367 

368 # ─── Cleanup ─── 

369 

370 @staticmethod 

371 def expire_stale_sessions(db: Session) -> int: 

372 """Mark expired waiting sessions. Called periodically.""" 

373 now = datetime.utcnow() 

374 stale = db.query(GameSession).filter( 

375 GameSession.status == 'waiting', 

376 GameSession.expires_at <= now, 

377 ).all() 

378 for s in stale: 

379 s.status = 'expired' 

380 s.ended_at = now 

381 db.flush() 

382 if stale: 

383 logger.info("Expired %d stale game sessions", len(stale)) 

384 return len(stale)