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
« 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
10from sqlalchemy import desc, func, or_
11from sqlalchemy.orm import Session
13from .models import GameSession, GameParticipant, User
14from .resonance_engine import ResonanceService
15from .encounter_service import EncounterService
17logger = logging.getLogger('hevolve_social')
19# ─── Constants ───
21VALID_STATUSES = ('waiting', 'active', 'completed', 'expired', 'cancelled')
22DEFAULT_EXPIRY_MINUTES = 30
23MIN_PLAYERS = 2
24MAX_PLAYERS_CAP = 8
27class GameService:
29 # ─── Session Lifecycle ───
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}'")
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
62 max_players = min(max(MIN_PLAYERS, max_players), MAX_PLAYERS_CAP)
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()
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()
89 logger.info("Game session created: %s (%s) by %s", session.id, game_type, host_user_id)
90 return session.to_dict()
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")
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")
109 participant = GameParticipant(
110 game_session_id=session_id,
111 user_id=user_id,
112 )
113 db.add(participant)
114 db.flush()
116 logger.info("User %s joined game %s", user_id, session_id)
117 return session.to_dict()
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()
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")
148 session.status = 'active'
149 session.started_at = datetime.utcnow()
150 session.current_round = 1
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()
159 logger.info("Game %s started (%s, %d players)",
160 session_id, session.game_type, len(session.participants))
161 return session.to_dict()
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}'")
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")
179 from .game_types import get_game_type
180 handler = get_game_type(session.game_type)
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}")
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
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)
199 db.flush()
200 return session.to_dict()
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)
208 results = handler.calculate_results(session.game_state, session.participants)
210 session.status = 'completed'
211 session.ended_at = datetime.utcnow()
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()
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)
226 if award:
227 participant.spark_earned = award.get('spark', 0)
228 participant.xp_earned = award.get('xp', 0)
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)
245 db.flush()
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()
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")
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")
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()
286 db.flush()
287 return session.to_dict()
289 # ─── Discovery & History ───
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)
306 sessions = query.order_by(desc(GameSession.created_at)).limit(limit).all()
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
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
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]
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 )
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
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)
368 # ─── Cleanup ───
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)