import json import asyncio from channels.generic.websocket import AsyncWebsocketConsumer from channels.db import database_sync_to_async from django.utils import timezone from django.urls import reverse from django.db import models from datetime import timedelta from play.models import QuizGame, QuizGameParticipant, QuizAnswer # Dictionary to track inactive check tasks per game game_check_tasks = {} class GameConsumer(AsyncWebsocketConsumer): @classmethod async def start_inactive_check(cls, join_code, channel_layer): """Start the inactive player check for a game if not already running.""" if join_code not in game_check_tasks or game_check_tasks[join_code].done(): async def check_inactive_players(): game_group_name = f'game_{join_code}' while True: try: await asyncio.sleep(30) # Check every 30 seconds try: game = await database_sync_to_async(QuizGame.objects.get)(join_code=join_code) cutoff_time = timezone.now() - timedelta(minutes=1) # Get active and inactive participants all_participants = await database_sync_to_async(lambda: list( game.participants.all().values('participant_id', 'display_name', 'last_heartbeat') ))() # Konvertiere datetime zu ISO Format String for p in all_participants: if p['last_heartbeat']: p['last_heartbeat'] = p['last_heartbeat'].isoformat() active_participants = [p for p in all_participants if p['last_heartbeat'] and timezone.datetime.fromisoformat(p['last_heartbeat']) >= cutoff_time] inactive_participants = [p for p in all_participants if not p['last_heartbeat'] or timezone.datetime.fromisoformat(p['last_heartbeat']) < cutoff_time] # Remove inactive participants if inactive_participants: for p in inactive_participants: # Benachrichtige andere über den gekickten Spieler await channel_layer.group_send( game_group_name, { 'type': 'player_left', 'player_name': p['display_name'], 'was_kicked': True } ) # Lösche den inaktiven Teilnehmer await database_sync_to_async(QuizGameParticipant.objects.filter( participant_id=p['participant_id'] ).delete)() # Broadcast update to all clients if active_participants or inactive_participants: await channel_layer.group_send( game_group_name, { 'type': 'participant_list_update', 'participants': active_participants } ) except QuizGame.DoesNotExist: break # Stop checking if game no longer exists except Exception as e: print(f'Error checking inactive players in game: {e}') await asyncio.sleep(5) # Wait before retry except asyncio.CancelledError: break except Exception as e: print(f'Error in game inactive check loop: {e}') await asyncio.sleep(5) game_check_tasks[join_code] = asyncio.create_task(check_inactive_players()) async def connect(self): self.join_code = self.scope['url_route']['kwargs']['join_code'] self.game_group_name = f'game_{self.join_code}' # Join game group await self.channel_layer.group_add( self.game_group_name, self.channel_name ) await self.accept() # Ensure inactive check is running for this game await self.start_inactive_check(self.join_code, self.channel_layer) async def disconnect(self, close_code): # Leave game group await self.channel_layer.group_discard( self.game_group_name, self.channel_name ) # Check if game should be deleted await self.cleanup_game() async def receive(self, text_data): text_data_json = json.loads(text_data) message_type = text_data_json['type'] if message_type == 'ping': # Respond with pong to keep connection alive await self.send(text_data=json.dumps({ 'type': 'pong' })) return if message_type == 'leave_game': participant_id = text_data_json.get('participant_id') if participant_id: try: # Get participant info before deletion participant = await database_sync_to_async(QuizGameParticipant.objects.get)(participant_id=participant_id) display_name = participant.display_name join_code = self.join_code # Delete participant await database_sync_to_async(participant.delete)() # Update participants list participants = await self.get_participants() await self.channel_layer.group_send( self.game_group_name, { 'type': 'participant_list_update', 'participants': participants } ) # Send redirect to home await self.send(text_data=json.dumps({ 'type': 'redirect', 'url': '/' })) except QuizGameParticipant.DoesNotExist: pass return if message_type == 'heartbeat': participant_id = text_data_json.get('participant_id') if participant_id: await self.update_participant_heartbeat(participant_id) # Update participants list after heartbeat participants = await self.get_participants() await self.channel_layer.group_send( self.game_group_name, { 'type': 'participant_list_update', 'participants': participants } ) return if message_type == 'submit_rating': participant_id = text_data_json['participant_id'] rating = text_data_json['rating'] success = await self.save_rating(participant_id, rating) if success: await self.send(text_data=json.dumps({ 'type': 'rating_confirmed' })) elif message_type == 'submit_answer': participant_id = text_data_json['participant_id'] answer = text_data_json['answer'] time_remaining = text_data_json.get('time_remaining', 0) # Save answer and update participant score await self.save_answer(participant_id, answer, time_remaining) # Notify host about the new answer await self.channel_layer.group_send( self.game_group_name, { 'type': 'participant_answer', 'answer': answer } ) elif message_type == 'start_game': host_id = text_data_json['host_id'] if await self.verify_host(host_id): await self.advance_to_next_question() elif message_type == 'get_answer_stats': stats = await self.get_answer_stats() await self.send(text_data=json.dumps({ 'type': 'answer_stats', 'stats': stats })) elif message_type == 'update_participants': participants = await self.get_participants() await self.channel_layer.group_send( self.game_group_name, { 'type': 'participant_list_update', 'participants': participants } ) elif message_type == 'next_question': host_id = text_data_json['host_id'] if await self.verify_host(host_id): await self.advance_to_next_question() elif message_type == 'finish_game': host_id = text_data_json['host_id'] if await self.verify_host(host_id): await self.finish_game() elif message_type == 'advance_to_scores': host_id = text_data_json['host_id'] if await self.verify_host(host_id): await self.show_scores() async def participant_answer(self, event): await self.send(text_data=json.dumps({ 'type': 'participant_answer', 'answer': event['answer'] })) async def participant_list_update(self, event): await self.send(text_data=json.dumps({ 'type': 'participant_list_update', 'participants': event['participants'] })) async def player_left(self, event): await self.send(text_data=json.dumps({ 'type': 'player_left', 'player_name': event['player_name'], 'was_kicked': event.get('was_kicked', False) })) async def game_state_update(self, event): await self.send(text_data=json.dumps({ 'type': 'game_state_update', 'action': event['action'], 'redirect_url': event.get('redirect_url') })) async def update_participant_heartbeat(self, participant_id): """Update the last heartbeat timestamp for a participant.""" try: participant = await database_sync_to_async(QuizGameParticipant.objects.get)( participant_id=participant_id ) participant.last_heartbeat = timezone.now() await database_sync_to_async(participant.save)() except QuizGameParticipant.DoesNotExist: pass @database_sync_to_async def verify_host(self, host_id): try: quiz_game = QuizGame.objects.get(join_code=self.join_code) return quiz_game.host_id == host_id except QuizGame.DoesNotExist: return False @database_sync_to_async def save_answer(self, participant_id, answer_index, time_remaining): try: participant = QuizGameParticipant.objects.get( quiz_game__join_code=self.join_code, participant_id=participant_id ) quiz_game = participant.quiz_game current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index] # Check if answer is correct is_correct = False if answer_index >= 0: # -1 means timeout question_data = json.loads(current_question.data) is_correct = question_data['options'][answer_index]['is_correct'] # Calculate score based on correctness and time score = 0 if is_correct: base_score = 1000 time_factor = time_remaining / 30000 # 30 seconds max score = int(base_score * (0.5 + 0.5 * time_factor)) # Update or create answer in QuizAnswer table answer_obj, created = QuizAnswer.objects.get_or_create( participant=participant, question_index=quiz_game.current_question_index, defaults={ 'answer_index': answer_index, 'is_correct': is_correct, 'score': score, 'time_remaining': time_remaining } ) if not created: answer_obj.answer_index = answer_index answer_obj.is_correct = is_correct answer_obj.score = score answer_obj.time_remaining = time_remaining answer_obj.save() participant.score += score participant.last_answer_correct = is_correct participant.save() return True except (QuizGameParticipant.DoesNotExist, IndexError): return False @database_sync_to_async def get_answer_stats(self): try: quiz_game = QuizGame.objects.get(join_code=self.join_code) # Count answers for each option in the current question stats = {} answers = QuizAnswer.objects.filter( participant__quiz_game=quiz_game, question_index=quiz_game.current_question_index ) for answer in answers: if answer.answer_index >= 0: # Ignore timeouts (-1) stats[answer.answer_index] = stats.get(answer.answer_index, 0) + 1 return stats except QuizGame.DoesNotExist: return {} @database_sync_to_async def save_rating(self, participant_id, rating): from library.models import QuizRating try: game = QuizGame.objects.get(join_code=self.join_code) participant = QuizGameParticipant.objects.get( quiz_game=game, participant_id=participant_id ) # Update or create rating rating_obj, created = QuizRating.objects.get_or_create( quiz=game.quiz_id, participant_id=participant_id, defaults={'rating': rating} ) if not created: rating_obj.rating = rating rating_obj.save() return True except (QuizGame.DoesNotExist, QuizGameParticipant.DoesNotExist): return False @database_sync_to_async def cleanup_game(self): try: game = QuizGame.objects.get(join_code=self.join_code) # Get all active participants (heartbeat within last minute) from django.utils import timezone active_participants = QuizGameParticipant.objects.filter( quiz_game=game, last_heartbeat__gte=timezone.now() - timezone.timedelta(minutes=1) ).count() # If no active participants and game is finished for more than 5 minutes, delete it if active_participants == 0 and game.current_state == 'finished': if game.question_start_time and (timezone.now() - game.question_start_time).total_seconds() > 300: game.delete() except QuizGame.DoesNotExist: pass @database_sync_to_async def get_participants(self): try: quiz_game = QuizGame.objects.get(join_code=self.join_code) participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game) return [ { 'id': p.participant_id, 'display_name': p.display_name, 'score': p.score } for p in participants ] except QuizGame.DoesNotExist: return [] @database_sync_to_async def _advance_to_next_question(self): try: quiz_game = QuizGame.objects.get(join_code=self.join_code) quiz = quiz_game.quiz_id # Move to next question quiz_game.current_question_index += 1 # Check if we've reached the end if quiz_game.current_question_index >= quiz.questions.count(): quiz_game.current_state = 'finished' quiz_game.save() return 'finished' else: # Update game state and start time quiz_game.current_state = 'question' quiz_game.question_start_time = timezone.now() quiz_game.save() return 'question' except QuizGame.DoesNotExist: return None async def advance_to_next_question(self): result = await self._advance_to_next_question() if result == 'finished': # Notify clients to redirect to finished page await self.channel_layer.group_send( self.game_group_name, { 'type': 'game_state_update', 'action': 'finish_game', 'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code}) } ) elif result == 'question': # Notify clients to redirect to next question await self.channel_layer.group_send( self.game_group_name, { 'type': 'game_state_update', 'action': 'next_question', 'redirect_url': reverse('play:question', kwargs={'join_code': self.join_code}) } ) @database_sync_to_async def _show_scores(self): try: quiz_game = QuizGame.objects.get(join_code=self.join_code) quiz_game.current_state = 'scores' quiz_game.save() return True except QuizGame.DoesNotExist: return False async def show_scores(self): success = await self._show_scores() if success: # Notify clients to redirect to scores page await self.channel_layer.group_send( self.game_group_name, { 'type': 'game_state_update', 'action': 'show_scores', 'redirect_url': reverse('play:scores', kwargs={'join_code': self.join_code}) } ) @database_sync_to_async def _finish_game(self): try: quiz_game = QuizGame.objects.get(join_code=self.join_code) quiz_game.current_state = 'finished' quiz_game.save() return True except QuizGame.DoesNotExist: return False async def finish_game(self): success = await self._finish_game() if success: # Notify clients to redirect to finished page await self.channel_layer.group_send( self.game_group_name, { 'type': 'game_state_update', 'action': 'finish_game', 'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code}) } )