Python · 34084 bytes Raw Blame History
1 # apps/trees/views.py
2 from django.http import HttpResponse
3 from django.db import models
4 from rest_framework import viewsets, status
5 from rest_framework.decorators import action
6 from rest_framework.response import Response
7 from django.utils import timezone
8 from datetime import timedelta
9 from .models import FileSystemTree, DirectoryNode, GameSession
10 from .serializers import (
11 FileSystemTreeSerializer, DirectoryNodeSerializer,
12 GameSessionSerializer, GameCommandSerializer
13 )
14
15
16 class FileSystemTreeViewSet(viewsets.ModelViewSet):
17 """ViewSet for filesystem trees"""
18 queryset = FileSystemTree.objects.all()
19 serializer_class = FileSystemTreeSerializer
20
21 @action(detail=False, methods=['get'])
22 def command_reference(self, request):
23 """Get command reference"""
24 commands = {
25 "navigation": [
26 {
27 "command": "cd <directory>",
28 "description": "Change to specified directory",
29 "examples": ["cd projects", "cd /home/sarah", "cd .."]
30 },
31 {
32 "command": "cd",
33 "description": "Go to home directory",
34 "examples": ["cd"]
35 },
36 {
37 "command": "pushd <directory>",
38 "description": "Push directory onto stack and change to it",
39 "examples": ["pushd /var/log", "pushd ~/Documents"]
40 },
41 {
42 "command": "popd",
43 "description": "Pop directory from stack and change to it",
44 "examples": ["popd"]
45 }
46 ],
47 "exploration": [
48 {
49 "command": "ls [-la]",
50 "description": "List directory contents",
51 "examples": ["ls", "ls -l", "ls -la"],
52 "options": {
53 "-l": "Long format with details",
54 "-a": "Show hidden entries (. and ..)"
55 }
56 },
57 {
58 "command": "pwd",
59 "description": "Print current working directory",
60 "examples": ["pwd"]
61 },
62 {
63 "command": "tree [-L depth]",
64 "description": "Display directory tree ([X] marks mole location)",
65 "examples": ["tree", "tree -L 2", "tree -L 5"],
66 "options": {
67 "-L": "Limit depth (1-5)"
68 }
69 },
70 {
71 "command": "dirs",
72 "description": "Display directory stack",
73 "examples": ["dirs"]
74 }
75 ],
76 "utility": [
77 {
78 "command": "echo <text>",
79 "description": "Display text or variables",
80 "examples": ["echo hello", "echo $HOME", "echo $PWD"],
81 "variables": {
82 "$HOME": "Home directory path",
83 "$PWD": "Current directory path",
84 "$OLDPWD": "Previous directory path"
85 }
86 },
87 {
88 "command": "help",
89 "description": "Show command help",
90 "examples": ["help"]
91 }
92 ],
93 "game": [
94 {
95 "command": "killall moles",
96 "description": "Eliminate moles when in the same directory",
97 "examples": ["killall moles"]
98 },
99 {
100 "command": "score",
101 "description": "Show current score and moles killed",
102 "examples": ["score"]
103 },
104 {
105 "command": "exit",
106 "description": "End the game and save your score",
107 "examples": ["exit"]
108 }
109 ],
110 "special_paths": [
111 {
112 "path": "~",
113 "description": "Home directory",
114 "examples": ["cd ~", "cd ~/Documents"]
115 },
116 {
117 "path": "-",
118 "description": "Previous directory",
119 "examples": ["cd -"]
120 },
121 {
122 "path": "..",
123 "description": "Parent directory",
124 "examples": ["cd ..", "cd ../projects"]
125 }
126 ]
127 }
128 return Response(commands)
129
130 @action(detail=False, methods=['get'])
131 def fhs_reference(self, request):
132 """Get FHS directory reference"""
133 fhs_dirs = [
134 {"path": "/bin", "name": "bin", "desc": "Essential command binaries"},
135 {"path": "/boot", "name": "boot", "desc": "Static files of the boot loader"},
136 {"path": "/dev", "name": "dev", "desc": "Device files"},
137 {"path": "/etc", "name": "etc", "desc": "Host-specific system configuration"},
138 {"path": "/home", "name": "home", "desc": "User home directories"},
139 {"path": "/lib", "name": "lib", "desc": "Essential shared libraries and kernel modules"},
140 {"path": "/media", "name": "media", "desc": "Mount points for removable media"},
141 {"path": "/mnt", "name": "mnt", "desc": "Mount point for temporarily mounted filesystems"},
142 {"path": "/opt", "name": "opt", "desc": "Add-on application software packages"},
143 {"path": "/proc", "name": "proc", "desc": "Virtual filesystem for process information"},
144 {"path": "/root", "name": "root", "desc": "Home directory for the root user"},
145 {"path": "/run", "name": "run", "desc": "Data relevant to running processes"},
146 {"path": "/sbin", "name": "sbin", "desc": "Essential system binaries"},
147 {"path": "/srv", "name": "srv", "desc": "Data for services provided by this system"},
148 {"path": "/sys", "name": "sys", "desc": "Virtual filesystem for system information"},
149 {"path": "/tmp", "name": "tmp", "desc": "Temporary files"},
150 {"path": "/usr", "name": "usr", "desc": "Secondary hierarchy"},
151 {"path": "/var", "name": "var", "desc": "Variable data"},
152 ]
153 return Response({"directories": fhs_dirs})
154
155 @action(detail=False, methods=['post'])
156 def create_game(self, request):
157 """Create a new game with a generated filesystem tree"""
158 tree_name = request.data.get('name', 'FHS Game Tree')
159 max_depth = request.data.get('max_depth', 5)
160 dirs_per_level = request.data.get('dirs_per_level', 3)
161
162 # Create and generate tree
163 tree = FileSystemTree.objects.create(name=tree_name)
164 tree.generate_tree(max_depth=max_depth, directories_per_level=dirs_per_level)
165
166 # Get initial timer info
167 initial_timer = tree.default_mole_timer
168 timer_distance = tree.calculate_path_distance(tree.player_location, tree.mole_location)
169
170 # Determine timer reason
171 if timer_distance <= 1:
172 timer_reason = "nearby"
173 elif timer_distance <= 3:
174 timer_reason = "close"
175 elif timer_distance <= 5:
176 timer_reason = "moderate distance"
177 else:
178 timer_reason = "far away"
179
180 # Create game session
181 player_name = request.data.get('player_name', 'Anonymous')
182 session = GameSession.objects.create(
183 tree=tree,
184 player_name=player_name
185 )
186
187 serializer = self.get_serializer(tree)
188 return Response({
189 'tree': serializer.data,
190 'session_id': session.id,
191 'mole_hint': f"The mole is hiding somewhere in the filesystem!",
192 'home_directory': tree.home_directory,
193 'initial_timer': initial_timer,
194 'timer_reason': timer_reason,
195 'timer_distance': timer_distance
196 }, status=status.HTTP_201_CREATED)
197
198 @action(detail=True, methods=['get'])
199 def current_directory(self, request, pk=None):
200 """Get current directory contents and player location"""
201 tree = self.get_object()
202
203 try:
204 current_dir = DirectoryNode.objects.get(
205 tree=tree,
206 path=tree.player_location
207 )
208 contents = current_dir.get_contents()
209
210 return Response({
211 'path': tree.player_location,
212 'contents': DirectoryNodeSerializer(contents, many=True).data,
213 'parent': current_dir.parent.path if current_dir.parent else None
214 })
215 except DirectoryNode.DoesNotExist:
216 return Response(
217 {'error': 'Current directory not found'},
218 status=status.HTTP_404_NOT_FOUND
219 )
220
221 @action(detail=True, methods=['get'])
222 def check_timer(self, request, pk=None):
223 """Check the current mole timer status"""
224 tree = self.get_object()
225
226 # Check if timer expired
227 expired, remaining = tree.check_mole_timer()
228
229 response_data = {
230 'timer_remaining': remaining,
231 'timer_expired': expired,
232 'mole_location': tree.mole_location,
233 'timer_paused': tree.timer_paused
234 }
235
236 # Handle escape if timer expired
237 if expired:
238 escape_data = tree.handle_mole_escape()
239 if escape_data:
240
241 mole_direction = tree.get_mole_direction()
242
243 escape_data['mole_direction'] = mole_direction
244
245 response_data.update({
246 'mole_escaped': True,
247 'escape_data': escape_data,
248 'mole_direction': mole_direction,
249 'message': f"The mole escaped from {escape_data['old_location']}! A new mole appeared!"
250 })
251
252 # Update session stats
253 session_id = request.query_params.get('session_id')
254 if session_id:
255 try:
256 session = GameSession.objects.get(id=session_id, tree=tree)
257 session.moles_escaped += 1
258 session.save()
259 except GameSession.DoesNotExist:
260 pass
261
262 return Response(response_data)
263
264 @action(detail=True, methods=['get'])
265 def timer_status(self, request, pk=None):
266 """Get detailed timer status for UI updates"""
267 tree = self.get_object()
268 expired, remaining = tree.check_mole_timer()
269
270 # Calculate warning level
271 warning_level = None
272 if not expired and remaining > 0:
273 if remaining <= 5:
274 warning_level = 'critical'
275 elif remaining <= 15:
276 warning_level = 'alert'
277 elif remaining <= 30:
278 warning_level = 'warning'
279
280 return Response({
281 'remaining': remaining,
282 'total': tree.default_mole_timer,
283 'percentage': (remaining / tree.default_mole_timer * 100) if tree.default_mole_timer > 0 else 0,
284 'warning_level': warning_level,
285 'expired': expired,
286 'paused': tree.timer_paused
287 })
288
289 @action(detail=True, methods=['post'])
290 def execute_command(self, request, pk=None):
291 """Execute a shell command in the game"""
292 tree = self.get_object()
293 command = request.data.get('command', '').strip()
294 session_id = request.data.get('session_id')
295
296 if not command:
297 return Response(
298 {'error': 'No command provided'},
299 status=status.HTTP_400_BAD_REQUEST
300 )
301
302 # Track commands for tree
303 tree.total_commands += 1
304 tree.save()
305
306 # Get session if provided
307 session = None
308 commands_before_mole = tree.total_commands # Track for scoring
309
310 if session_id:
311 try:
312 session = GameSession.objects.get(id=session_id, tree=tree)
313 session.add_command(command)
314 commands_before_mole = session.commands_used
315 except GameSession.DoesNotExist:
316 pass
317
318 # Check timer before executing command
319 expired, remaining = tree.check_mole_timer()
320
321 # Parse and execute command
322 parts = command.split()
323 cmd = parts[0] if parts else ""
324
325 response_data = {
326 'command': command,
327 'success': False,
328 'output': '',
329 'current_path': tree.player_location,
330 'mole_spawned': False,
331 'mole_direction': None,
332 'score': 0,
333 'timer_remaining': remaining,
334 'timer_warnings': [],
335 'new_timer': None,
336 'timer_reason': None,
337 'timer_distance': None
338 }
339
340 # Add timer warnings to output
341 if not expired and remaining > 0:
342 if remaining <= 5:
343 response_data['timer_warnings'].append({
344 'level': 'CRITICAL',
345 'message': f'Mole escaping! ({remaining}s remaining)'
346 })
347 elif remaining <= 15:
348 response_data['timer_warnings'].append({
349 'level': 'ALERT',
350 'message': f'Mole burrowing soon! ({remaining}s remaining)'
351 })
352 elif remaining <= 30:
353 response_data['timer_warnings'].append({
354 'level': 'WARNING',
355 'message': f'Mole detected! ({remaining}s remaining)'
356 })
357
358 if cmd == 'cd':
359 if len(parts) < 2:
360 # cd with no args goes to home directory
361 success, message = tree.move_player("~")
362 response_data['success'] = success
363 response_data['output'] = message if not success else ""
364 response_data['current_path'] = tree.player_location
365 else:
366 # Join all parts after 'cd' to handle paths with spaces
367 target = ' '.join(parts[1:])
368 # Strip trailing slash if present (except for root)
369 if target.endswith('/') and target != '/':
370 target = target.rstrip('/')
371
372 success, message = tree.move_player(target)
373 response_data['success'] = success
374 response_data['output'] = message
375 response_data['current_path'] = tree.player_location
376
377 if success and session:
378 session.directories_visited += 1
379 session.save()
380
381 elif cmd == 'pushd':
382 if len(parts) < 2:
383 # pushd with no args swaps top two directories on stack
384 if tree.directory_stack:
385 success, message = tree.push_directory()
386 response_data['success'] = success
387 response_data['output'] = message
388 else:
389 response_data['output'] = "pushd: no other directory"
390 else:
391 target = ' '.join(parts[1:])
392 # Strip trailing slash if present (except for root)
393 if target.endswith('/') and target != '/':
394 target = target.rstrip('/')
395
396 success, message = tree.push_directory(target)
397 response_data['success'] = success
398 response_data['output'] = message
399 response_data['current_path'] = tree.player_location
400
401 if success and session:
402 session.directories_visited += 1
403 session.save()
404
405 elif cmd == 'popd':
406 success, message = tree.pop_directory()
407 response_data['success'] = success
408 response_data['output'] = message
409 response_data['current_path'] = tree.player_location
410
411 if success and session:
412 session.directories_visited += 1
413 session.save()
414
415 elif cmd == 'dirs':
416 # Show directory stack
417 stack = tree.get_directory_stack()
418 if stack:
419 response_data['output'] = ' '.join(stack)
420 else:
421 response_data['output'] = tree.player_location
422 response_data['success'] = True
423
424 elif cmd == 'ls':
425 # Handle ls with options
426 show_all = '-a' in parts or '-la' in parts or '-al' in parts
427 long_format = '-l' in parts or '-la' in parts or '-al' in parts
428
429 try:
430 current_dir = DirectoryNode.objects.get(
431 tree=tree,
432 path=tree.player_location
433 )
434 contents = current_dir.get_contents()
435
436 output_lines = []
437
438 if show_all:
439 # Add . and .. entries
440 if long_format:
441 output_lines.append("drwxr-xr-x . " + current_dir.description)
442 if current_dir.parent:
443 output_lines.append("drwxr-xr-x .. " + current_dir.parent.description)
444 else:
445 output_lines.extend(['.', '..'])
446
447 if contents:
448 for d in contents:
449 if long_format:
450 output_lines.append(f"drwxr-xr-x {d.name} {d.description}")
451 else:
452 output_lines.append(d.name)
453
454 if long_format:
455 response_data['output'] = '\n'.join(output_lines)
456 else:
457 # Format in columns for regular ls
458 if output_lines:
459 response_data['output'] = ' '.join(output_lines)
460 else:
461 response_data['output'] = ''
462
463 response_data['success'] = True
464 except DirectoryNode.DoesNotExist:
465 response_data['output'] = "ls: cannot access directory"
466
467 elif cmd == 'pwd':
468 response_data['output'] = tree.player_location
469 response_data['success'] = True
470
471 elif cmd == 'echo':
472 # Simple echo implementation
473 if len(parts) > 1:
474 # Handle special variables
475 echo_text = ' '.join(parts[1:])
476 if echo_text == '$HOME':
477 response_data['output'] = tree.home_directory
478 elif echo_text == '$PWD':
479 response_data['output'] = tree.player_location
480 elif echo_text == '$OLDPWD':
481 response_data['output'] = tree.previous_location or ''
482 else:
483 response_data['output'] = echo_text
484 else:
485 response_data['output'] = ''
486 response_data['success'] = True
487
488 elif cmd == 'tree':
489 # Parse options
490 show_all = '-a' in parts
491 max_depth = 3 # Default depth
492
493 # Check for -L option
494 if '-L' in parts:
495 try:
496 depth_index = parts.index('-L') + 1
497 if depth_index < len(parts):
498 max_depth = int(parts[depth_index])
499 max_depth = min(max(max_depth, 1), 5) # Limit between 1-5
500 except (ValueError, IndexError):
501 pass
502
503 try:
504 current_dir = DirectoryNode.objects.get(
505 tree=tree,
506 path=tree.player_location
507 )
508
509 # Build tree output
510 output_lines = [tree.player_location]
511
512 def build_tree_output(node, prefix="", is_last=True, depth=0):
513 if depth >= max_depth:
514 return
515
516 children = list(node.get_contents().order_by('name'))
517
518 for i, child in enumerate(children):
519 is_last_child = i == len(children) - 1
520
521 # Determine if this directory contains the mole
522 has_mole_in_subtree = False
523 if child.path == tree.mole_location:
524 has_mole_in_subtree = True
525 else:
526 # Check if mole is in any subdirectory
527 all_descendants = DirectoryNode.objects.filter(
528 tree=tree,
529 path__startswith=child.path + '/'
530 )
531 if any(d.path == tree.mole_location for d in all_descendants):
532 has_mole_in_subtree = True
533
534 # Build the tree branch characters
535 connector = "└── " if is_last_child else "├── "
536
537 # Format the directory name
538 if has_mole_in_subtree:
539 # Use X to indicate mole presence
540 dir_display = f"[X] {child.name}"
541 else:
542 dir_display = child.name
543
544 output_lines.append(f"{prefix}{connector}{dir_display}")
545
546 # Recurse into subdirectories
547 if depth + 1 < max_depth:
548 extension = " " if is_last_child else "│ "
549 build_tree_output(child, prefix + extension, is_last_child, depth + 1)
550
551 build_tree_output(current_dir)
552
553 # Add directory count at the end
554 total_shown = len(output_lines) - 1
555 if total_shown == 0:
556 output_lines.append("\n0 directories")
557 else:
558 dir_text = "directory" if total_shown == 1 else "directories"
559 output_lines.append(f"\n{total_shown} {dir_text}")
560
561 response_data['output'] = '\n'.join(output_lines)
562 response_data['success'] = True
563
564 except DirectoryNode.DoesNotExist:
565 response_data['output'] = "tree: cannot access directory"
566
567 elif cmd == 'killall' and len(parts) > 1 and parts[1] == 'moles':
568 if tree.check_win_condition():
569 # Check if killed before timer expired
570 expired, remaining = tree.check_mole_timer()
571
572 # Track old mole location for stats
573 old_mole_location = tree.mole_location
574
575 # Update mole kill count
576 tree.moles_killed += 1
577 tree.save()
578
579 # Record stats for session
580 if session:
581 # Calculate commands used for this mole
582 commands_for_mole = session.commands_used - commands_before_mole + 1 # +1 for killall
583
584 # Calculate time (simplified for now - would need to track per-mole start time)
585 time_for_mole = timezone.now() - session.started_at
586
587 # Calculate distance traveled (simplified - just the path distance)
588 distance = tree.calculate_path_distance(tree.home_directory, old_mole_location)
589
590 session.record_mole_kill(
591 old_mole_location,
592 commands_for_mole,
593 time_for_mole,
594 distance
595 )
596 response_data['score'] = session.calculate_score()
597
598 # Spawn new mole
599 success, new_timer, timer_reason, timer_distance = tree.spawn_new_mole()
600 if success:
601 mole_direction = tree.get_mole_direction()
602
603 # Include timer info in message
604 timer_msg = f"Timer: {new_timer}s (mole is {timer_reason})"
605
606 if not expired:
607 response_data['output'] = f"🎉 You eliminated the mole with {remaining}s to spare! (Total moles killed: {tree.moles_killed})\n🐭 A new mole has appeared! {timer_msg}"
608 else:
609 response_data['output'] = f"🎉 You eliminated the mole! (Total moles killed: {tree.moles_killed})\n🐭 A new mole has appeared! {timer_msg}"
610
611 response_data['success'] = True
612 response_data['mole_spawned'] = True
613 response_data['mole_direction'] = mole_direction
614 response_data['moles_killed'] = tree.moles_killed
615 response_data['new_mole_location'] = tree.mole_location
616 response_data['new_timer'] = new_timer
617 response_data['timer_reason'] = timer_reason
618 response_data['timer_distance'] = timer_distance
619 else:
620 response_data['output'] = "🎉 You eliminated the mole! Unable to spawn new mole."
621 response_data['success'] = True
622 else:
623 response_data['output'] = "No moles found in this directory."
624 response_data['success'] = True
625
626 elif cmd == 'score':
627 # New command to check current score
628 if session:
629 response_data['output'] = f"Score: {session.calculate_score()} | Moles killed: {session.moles_killed}"
630 response_data['score'] = session.calculate_score()
631 else:
632 response_data['output'] = "No active session to score."
633 response_data['success'] = True
634
635 elif cmd == 'exit':
636 # Complete the game
637 tree.complete_game()
638
639 # Complete the session if it exists
640 if session:
641 session.completed_at = timezone.now()
642 session.time_taken = session.completed_at - session.started_at
643 session.save()
644
645 response_data['output'] = f"Game Over! Final score: {session.calculate_score()} | Moles killed: {session.moles_killed}"
646 response_data['score'] = session.calculate_score()
647 response_data['game_completed'] = True
648 response_data['session_completed'] = True
649 response_data['final_stats'] = {
650 'score': session.calculate_score(),
651 'moles_killed': session.moles_killed,
652 'moles_escaped': session.moles_escaped,
653 'commands_used': session.commands_used,
654 'time_taken': str(session.time_taken),
655 'directories_visited': session.directories_visited
656 }
657 else:
658 response_data['output'] = "Game ended. No session to record."
659 response_data['game_completed'] = True
660
661 response_data['success'] = True
662
663 elif cmd == 'help':
664 response_data['output'] = """Available commands:
665 cd <directory> - Change directory (supports ~, -, and ..)
666 cd - Go to home directory
667 pushd <directory> - Push directory onto stack and change to it
668 popd - Pop directory from stack and change to it
669 dirs - Display directory stack
670 ls [-la] - List directory contents
671 pwd - Print working directory
672 echo <text> - Display text (supports $HOME, $PWD, $OLDPWD)
673 tree [-L depth] - Display directory tree (use -L to limit depth)
674 killall moles - Eliminate moles (when in the same directory)
675 score - Show current score and moles killed
676 exit - End the game and save your score
677 help - Show this help message
678
679 Special paths:
680 ~ - Home directory
681 - - Previous directory
682 .. - Parent directory"""
683 response_data['success'] = True
684
685 else:
686 response_data['output'] = f"bash: {cmd}: command not found"
687
688 return Response(response_data)
689
690 @action(detail=True, methods=['get'])
691 def hint(self, request, pk=None):
692 """Get a hint about the mole's location"""
693 tree = self.get_object()
694
695 if not tree.mole_location:
696 return Response({'hint': 'No mole in this tree!'})
697
698 mole_depth = tree.mole_location.count('/')
699 player_depth = tree.player_location.count('/')
700
701 hints = []
702 if mole_depth > player_depth:
703 hints.append("The mole is deeper in the filesystem than you are.")
704 elif mole_depth < player_depth:
705 hints.append("The mole is in a shallower directory than you are.")
706 else:
707 hints.append("You're at the same depth as the mole!")
708
709 # Give a path hint
710 mole_parts = tree.mole_location.split('/')
711 player_parts = tree.player_location.split('/')
712
713 # Find common path
714 common_parts = []
715 for i, (m, p) in enumerate(zip(mole_parts, player_parts)):
716 if m == p:
717 common_parts.append(m)
718 else:
719 break
720
721 if len(common_parts) == len(mole_parts):
722 hints.append("You're in the mole's directory! Use 'killall moles'!")
723 elif len(common_parts) > 1:
724 hints.append(f"You share a common path with the mole: {'/'.join(common_parts) or '/'}")
725
726 return Response({'hints': hints})
727
728
729 class DirectoryNodeViewSet(viewsets.ReadOnlyModelViewSet):
730 """ViewSet for browsing directory nodes"""
731 queryset = DirectoryNode.objects.all()
732 serializer_class = DirectoryNodeSerializer
733
734 def get_queryset(self):
735 queryset = super().get_queryset()
736 tree_id = self.request.query_params.get('tree', None)
737 if tree_id:
738 queryset = queryset.filter(tree_id=tree_id)
739 return queryset
740
741
742 class GameSessionViewSet(viewsets.ModelViewSet):
743 """ViewSet for game sessions"""
744 queryset = GameSession.objects.all()
745 serializer_class = GameSessionSerializer
746
747 @action(detail=False, methods=['post'])
748 def save_player_name(self, request):
749 """Save player name for a completed session"""
750 session_id = request.data.get('session_id')
751 player_name = request.data.get('player_name', 'Anonymous')
752
753 if not session_id:
754 return Response(
755 {'error': 'No session ID provided'},
756 status=status.HTTP_400_BAD_REQUEST
757 )
758
759 try:
760 session = GameSession.objects.get(id=session_id)
761 session.player_name = player_name
762 session.save()
763
764 # Get leaderboard position
765 better_scores = GameSession.objects.filter(
766 completed_at__isnull=False
767 ).exclude(id=session_id).annotate(
768 score=models.F('moles_killed') * 1000 # Simplified score calculation
769 ).filter(score__gt=session.calculate_score()).count()
770
771 return Response({
772 'success': True,
773 'player_name': player_name,
774 'score': session.calculate_score(),
775 'leaderboard_position': better_scores + 1
776 })
777 except GameSession.DoesNotExist:
778 return Response(
779 {'error': 'Session not found'},
780 status=status.HTTP_404_NOT_FOUND
781 )
782
783 @action(detail=False, methods=['get'])
784 def leaderboard(self, request):
785 """Get the leaderboard of top scores"""
786 # Get top 10 completed sessions
787 completed_sessions = GameSession.objects.filter(
788 completed_at__isnull=False
789 )[:20] # Get more than 10 to handle sorting by score
790
791 leaderboard_data = []
792 for session in completed_sessions:
793 score = session.calculate_score()
794 leaderboard_data.append({
795 'rank': 0, # Will be set after sorting
796 'player_name': session.player_name,
797 'score': score,
798 'moles_killed': session.moles_killed,
799 'moles_escaped': session.moles_escaped,
800 'commands_used': session.commands_used,
801 'time_taken': str(session.time_taken) if session.time_taken else 'N/A',
802 'completed_at': session.completed_at
803 })
804
805 # Sort by score (highest first)
806 leaderboard_data.sort(key=lambda x: x['score'], reverse=True)
807
808 # Keep only top 10
809 leaderboard_data = leaderboard_data[:10]
810
811 # Update ranks after sorting
812 for i, entry in enumerate(leaderboard_data, 1):
813 entry['rank'] = i
814
815 return Response({
816 'leaderboard': leaderboard_data,
817 'total_games': GameSession.objects.filter(completed_at__isnull=False).count()
818 })