493 lines
20 KiB
Python
493 lines
20 KiB
Python
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})
|
|
}
|
|
)
|