Python · 29911 bytes Raw Blame History
1 # apps/trees/views.py
2 from django.http import HttpResponse
3 from rest_framework import viewsets, status
4 from rest_framework.decorators import action
5 from rest_framework.response import Response
6 from django.utils import timezone
7 from datetime import timedelta
8 from .models import FileSystemTree, DirectoryNode, GameSession
9 from .serializers import (
10 FileSystemTreeSerializer, DirectoryNodeSerializer,
11 GameSessionSerializer, GameCommandSerializer
12 )
13
14
15 class FileSystemTreeViewSet(viewsets.ModelViewSet):
16 """ViewSet for filesystem trees"""
17 queryset = FileSystemTree.objects.all()
18 serializer_class = FileSystemTreeSerializer
19
20 @action(detail=False, methods=['get'])
21 def command_reference(self, request):
22 """Get command reference"""
23 commands = {
24 "navigation": [
25 {
26 "command": "cd <directory>",
27 "description": "Change to specified directory",
28 "examples": ["cd projects", "cd /home/sarah", "cd .."]
29 },
30 {
31 "command": "cd",
32 "description": "Go to home directory",
33 "examples": ["cd"]
34 },
35 {
36 "command": "pushd <directory>",
37 "description": "Push directory onto stack and change to it",
38 "examples": ["pushd /var/log", "pushd ~/Documents"]
39 },
40 {
41 "command": "popd",
42 "description": "Pop directory from stack and change to it",
43 "examples": ["popd"]
44 }
45 ],
46 "exploration": [
47 {
48 "command": "ls [-la]",
49 "description": "List directory contents",
50 "examples": ["ls", "ls -l", "ls -la"],
51 "options": {
52 "-l": "Long format with details",
53 "-a": "Show hidden entries (. and ..)"
54 }
55 },
56 {
57 "command": "pwd",
58 "description": "Print current working directory",
59 "examples": ["pwd"]
60 },
61 {
62 "command": "tree [-L depth]",
63 "description": "Display directory tree ([X] marks mole location)",
64 "examples": ["tree", "tree -L 2", "tree -L 5"],
65 "options": {
66 "-L": "Limit depth (1-5)"
67 }
68 },
69 {
70 "command": "dirs",
71 "description": "Display directory stack",
72 "examples": ["dirs"]
73 }
74 ],
75 "utility": [
76 {
77 "command": "echo <text>",
78 "description": "Display text or variables",
79 "examples": ["echo hello", "echo $HOME", "echo $PWD"],
80 "variables": {
81 "$HOME": "Home directory path",
82 "$PWD": "Current directory path",
83 "$OLDPWD": "Previous directory path"
84 }
85 },
86 {
87 "command": "help",
88 "description": "Show command help",
89 "examples": ["help"]
90 }
91 ],
92 "game": [
93 {
94 "command": "killall moles",
95 "description": "Eliminate moles when in the same directory",
96 "examples": ["killall moles"]
97 },
98 {
99 "command": "score",
100 "description": "Show current score and moles killed",
101 "examples": ["score"]
102 }
103 ],
104 "special_paths": [
105 {
106 "path": "~",
107 "description": "Home directory",
108 "examples": ["cd ~", "cd ~/Documents"]
109 },
110 {
111 "path": "-",
112 "description": "Previous directory",
113 "examples": ["cd -"]
114 },
115 {
116 "path": "..",
117 "description": "Parent directory",
118 "examples": ["cd ..", "cd ../projects"]
119 }
120 ]
121 }
122 return Response(commands)
123
124 @action(detail=False, methods=['get'])
125 def fhs_reference(self, request):
126 """Get FHS directory reference"""
127 fhs_dirs = [
128 {"path": "/bin", "name": "bin", "desc": "Essential command binaries"},
129 {"path": "/boot", "name": "boot", "desc": "Static files of the boot loader"},
130 {"path": "/dev", "name": "dev", "desc": "Device files"},
131 {"path": "/etc", "name": "etc", "desc": "Host-specific system configuration"},
132 {"path": "/home", "name": "home", "desc": "User home directories"},
133 {"path": "/lib", "name": "lib", "desc": "Essential shared libraries and kernel modules"},
134 {"path": "/media", "name": "media", "desc": "Mount points for removable media"},
135 {"path": "/mnt", "name": "mnt", "desc": "Mount point for temporarily mounted filesystems"},
136 {"path": "/opt", "name": "opt", "desc": "Add-on application software packages"},
137 {"path": "/proc", "name": "proc", "desc": "Virtual filesystem for process information"},
138 {"path": "/root", "name": "root", "desc": "Home directory for the root user"},
139 {"path": "/run", "name": "run", "desc": "Data relevant to running processes"},
140 {"path": "/sbin", "name": "sbin", "desc": "Essential system binaries"},
141 {"path": "/srv", "name": "srv", "desc": "Data for services provided by this system"},
142 {"path": "/sys", "name": "sys", "desc": "Virtual filesystem for system information"},
143 {"path": "/tmp", "name": "tmp", "desc": "Temporary files"},
144 {"path": "/usr", "name": "usr", "desc": "Secondary hierarchy"},
145 {"path": "/var", "name": "var", "desc": "Variable data"},
146 ]
147 return Response({"directories": fhs_dirs})
148
149 @action(detail=False, methods=['post'])
150 def create_game(self, request):
151 """Create a new game with a generated filesystem tree"""
152 tree_name = request.data.get('name', 'FHS Game Tree')
153 max_depth = request.data.get('max_depth', 5)
154 dirs_per_level = request.data.get('dirs_per_level', 3)
155
156 # Create and generate tree
157 tree = FileSystemTree.objects.create(name=tree_name)
158 tree.generate_tree(max_depth=max_depth, directories_per_level=dirs_per_level)
159
160 # Get initial timer info
161 initial_timer = tree.default_mole_timer
162 timer_distance = tree.calculate_path_distance(tree.player_location, tree.mole_location)
163
164 # Determine timer reason
165 if timer_distance <= 1:
166 timer_reason = "nearby"
167 elif timer_distance <= 3:
168 timer_reason = "close"
169 elif timer_distance <= 5:
170 timer_reason = "moderate distance"
171 else:
172 timer_reason = "far away"
173
174 # Create game session
175 player_name = request.data.get('player_name', 'Anonymous')
176 session = GameSession.objects.create(
177 tree=tree,
178 player_name=player_name
179 )
180
181 serializer = self.get_serializer(tree)
182 return Response({
183 'tree': serializer.data,
184 'session_id': session.id,
185 'mole_hint': f"The mole is hiding somewhere in the filesystem!",
186 'home_directory': tree.home_directory,
187 'initial_timer': initial_timer,
188 'timer_reason': timer_reason,
189 'timer_distance': timer_distance
190 }, status=status.HTTP_201_CREATED)
191
192 @action(detail=True, methods=['get'])
193 def current_directory(self, request, pk=None):
194 """Get current directory contents and player location"""
195 tree = self.get_object()
196
197 try:
198 current_dir = DirectoryNode.objects.get(
199 tree=tree,
200 path=tree.player_location
201 )
202 contents = current_dir.get_contents()
203
204 return Response({
205 'path': tree.player_location,
206 'contents': DirectoryNodeSerializer(contents, many=True).data,
207 'parent': current_dir.parent.path if current_dir.parent else None
208 })
209 except DirectoryNode.DoesNotExist:
210 return Response(
211 {'error': 'Current directory not found'},
212 status=status.HTTP_404_NOT_FOUND
213 )
214
215 @action(detail=True, methods=['get'])
216 def check_timer(self, request, pk=None):
217 """Check the current mole timer status"""
218 tree = self.get_object()
219
220 # Check if timer expired
221 expired, remaining = tree.check_mole_timer()
222
223 response_data = {
224 'timer_remaining': remaining,
225 'timer_expired': expired,
226 'mole_location': tree.mole_location,
227 'timer_paused': tree.timer_paused
228 }
229
230 # Handle escape if timer expired
231 if expired:
232 escape_data = tree.handle_mole_escape()
233 if escape_data:
234
235 mole_direction = tree.get_mole_direction()
236
237 escape_data['mole_direction'] = mole_direction
238
239 response_data.update({
240 'mole_escaped': True,
241 'escape_data': escape_data,
242 'mole_direction': mole_direction, # Add this line
243 'message': f"The mole escaped from {escape_data['old_location']}! A new mole appeared!"
244 })
245
246 # Update session stats
247 session_id = request.query_params.get('session_id')
248 if session_id:
249 try:
250 session = GameSession.objects.get(id=session_id, tree=tree)
251 session.moles_escaped += 1
252 session.save()
253 except GameSession.DoesNotExist:
254 pass
255
256 response_data.update({
257 'mole_escaped': True,
258 'escape_data': escape_data,
259 'message': f"The mole escaped from {escape_data['old_location']}! A new mole appeared!"
260 })
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 from {tree.mole_location}! ({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 at {tree.mole_location}! ({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 target = parts[1]
367 success, message = tree.move_player(target)
368 response_data['success'] = success
369 response_data['output'] = message
370 response_data['current_path'] = tree.player_location
371
372 if success and session:
373 session.directories_visited += 1
374 session.save()
375
376 elif cmd == 'pushd':
377 if len(parts) < 2:
378 # pushd with no args swaps top two directories on stack
379 if tree.directory_stack:
380 success, message = tree.push_directory()
381 response_data['success'] = success
382 response_data['output'] = message
383 else:
384 response_data['output'] = "pushd: no other directory"
385 else:
386 target = parts[1]
387 success, message = tree.push_directory(target)
388 response_data['success'] = success
389 response_data['output'] = message
390 response_data['current_path'] = tree.player_location
391
392 if success and session:
393 session.directories_visited += 1
394 session.save()
395
396 elif cmd == 'popd':
397 success, message = tree.pop_directory()
398 response_data['success'] = success
399 response_data['output'] = message
400 response_data['current_path'] = tree.player_location
401
402 if success and session:
403 session.directories_visited += 1
404 session.save()
405
406 elif cmd == 'dirs':
407 # Show directory stack
408 stack = tree.get_directory_stack()
409 if stack:
410 response_data['output'] = ' '.join(stack)
411 else:
412 response_data['output'] = tree.player_location
413 response_data['success'] = True
414
415 elif cmd == 'ls':
416 # Handle ls with options
417 show_all = '-a' in parts or '-la' in parts or '-al' in parts
418 long_format = '-l' in parts or '-la' in parts or '-al' in parts
419
420 try:
421 current_dir = DirectoryNode.objects.get(
422 tree=tree,
423 path=tree.player_location
424 )
425 contents = current_dir.get_contents()
426
427 output_lines = []
428
429 if show_all:
430 # Add . and .. entries
431 if long_format:
432 output_lines.append("drwxr-xr-x . " + current_dir.description)
433 if current_dir.parent:
434 output_lines.append("drwxr-xr-x .. " + current_dir.parent.description)
435 else:
436 output_lines.extend(['.', '..'])
437
438 if contents:
439 for d in contents:
440 if long_format:
441 output_lines.append(f"drwxr-xr-x {d.name} {d.description}")
442 else:
443 output_lines.append(d.name)
444
445 if long_format:
446 response_data['output'] = '\n'.join(output_lines)
447 else:
448 # Format in columns for regular ls
449 if output_lines:
450 response_data['output'] = ' '.join(output_lines)
451 else:
452 response_data['output'] = ''
453
454 response_data['success'] = True
455 except DirectoryNode.DoesNotExist:
456 response_data['output'] = "ls: cannot access directory"
457
458 elif cmd == 'pwd':
459 response_data['output'] = tree.player_location
460 response_data['success'] = True
461
462 elif cmd == 'echo':
463 # Simple echo implementation
464 if len(parts) > 1:
465 # Handle special variables
466 echo_text = ' '.join(parts[1:])
467 if echo_text == '$HOME':
468 response_data['output'] = tree.home_directory
469 elif echo_text == '$PWD':
470 response_data['output'] = tree.player_location
471 elif echo_text == '$OLDPWD':
472 response_data['output'] = tree.previous_location or ''
473 else:
474 response_data['output'] = echo_text
475 else:
476 response_data['output'] = ''
477 response_data['success'] = True
478
479 elif cmd == 'tree':
480 # Parse options
481 show_all = '-a' in parts
482 max_depth = 3 # Default depth
483
484 # Check for -L option
485 if '-L' in parts:
486 try:
487 depth_index = parts.index('-L') + 1
488 if depth_index < len(parts):
489 max_depth = int(parts[depth_index])
490 max_depth = min(max(max_depth, 1), 5) # Limit between 1-5
491 except (ValueError, IndexError):
492 pass
493
494 try:
495 current_dir = DirectoryNode.objects.get(
496 tree=tree,
497 path=tree.player_location
498 )
499
500 # Build tree output
501 output_lines = [tree.player_location]
502
503 def build_tree_output(node, prefix="", is_last=True, depth=0):
504 if depth >= max_depth:
505 return
506
507 children = list(node.get_contents().order_by('name'))
508
509 for i, child in enumerate(children):
510 is_last_child = i == len(children) - 1
511
512 # Determine if this directory contains the mole
513 has_mole_in_subtree = False
514 if child.path == tree.mole_location:
515 has_mole_in_subtree = True
516 else:
517 # Check if mole is in any subdirectory
518 all_descendants = DirectoryNode.objects.filter(
519 tree=tree,
520 path__startswith=child.path + '/'
521 )
522 if any(d.path == tree.mole_location for d in all_descendants):
523 has_mole_in_subtree = True
524
525 # Build the tree branch characters
526 connector = "└── " if is_last_child else "├── "
527
528 # Format the directory name
529 if has_mole_in_subtree:
530 # Use X to indicate mole presence
531 dir_display = f"[X] {child.name}"
532 else:
533 dir_display = child.name
534
535 output_lines.append(f"{prefix}{connector}{dir_display}")
536
537 # Recurse into subdirectories
538 if depth + 1 < max_depth:
539 extension = " " if is_last_child else "│ "
540 build_tree_output(child, prefix + extension, is_last_child, depth + 1)
541
542 build_tree_output(current_dir)
543
544 # Add directory count at the end
545 total_shown = len(output_lines) - 1
546 if total_shown == 0:
547 output_lines.append("\n0 directories")
548 else:
549 dir_text = "directory" if total_shown == 1 else "directories"
550 output_lines.append(f"\n{total_shown} {dir_text}")
551
552 response_data['output'] = '\n'.join(output_lines)
553 response_data['success'] = True
554
555 except DirectoryNode.DoesNotExist:
556 response_data['output'] = "tree: cannot access directory"
557
558 elif cmd == 'killall' and len(parts) > 1 and parts[1] == 'moles':
559 if tree.check_win_condition():
560 # Check if killed before timer expired
561 expired, remaining = tree.check_mole_timer()
562
563 # Track old mole location for stats
564 old_mole_location = tree.mole_location
565
566 # Update mole kill count
567 tree.moles_killed += 1
568 tree.save()
569
570 # Record stats for session
571 if session:
572 # Calculate commands used for this mole
573 commands_for_mole = session.commands_used - commands_before_mole + 1 # +1 for killall
574
575 # Calculate time (simplified for now - would need to track per-mole start time)
576 time_for_mole = timezone.now() - session.started_at
577
578 # Calculate distance traveled (simplified - just the path distance)
579 distance = tree.calculate_path_distance(tree.home_directory, old_mole_location)
580
581 session.record_mole_kill(
582 old_mole_location,
583 commands_for_mole,
584 time_for_mole,
585 distance
586 )
587 response_data['score'] = session.calculate_score()
588
589 # Spawn new mole
590 success, new_timer, timer_reason, timer_distance = tree.spawn_new_mole()
591 if success:
592 mole_direction = tree.get_mole_direction()
593
594 # Include timer info in message
595 timer_msg = f"Timer: {new_timer}s (mole is {timer_reason})"
596
597 if not expired:
598 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}"
599 else:
600 response_data['output'] = f"🎉 You eliminated the mole! (Total moles killed: {tree.moles_killed})\n🐭 A new mole has appeared! {timer_msg}"
601
602 response_data['success'] = True
603 response_data['mole_spawned'] = True
604 response_data['mole_direction'] = mole_direction
605 response_data['moles_killed'] = tree.moles_killed
606 response_data['new_mole_location'] = tree.mole_location
607 response_data['new_timer'] = new_timer
608 response_data['timer_reason'] = timer_reason
609 response_data['timer_distance'] = timer_distance
610 else:
611 response_data['output'] = "🎉 You eliminated the mole! Unable to spawn new mole."
612 response_data['success'] = True
613 else:
614 response_data['output'] = "No moles found in this directory."
615 response_data['success'] = True
616
617 elif cmd == 'score':
618 # New command to check current score
619 if session:
620 response_data['output'] = f"Score: {session.calculate_score()} | Moles killed: {session.moles_killed}"
621 response_data['score'] = session.calculate_score()
622 else:
623 response_data['output'] = "No active session to score."
624 response_data['success'] = True
625
626 elif cmd == 'help':
627 response_data['output'] = """Available commands:
628 cd <directory> - Change directory (supports ~, -, and ..)
629 cd - Go to home directory
630 pushd <directory> - Push directory onto stack and change to it
631 popd - Pop directory from stack and change to it
632 dirs - Display directory stack
633 ls [-la] - List directory contents
634 pwd - Print working directory
635 echo <text> - Display text (supports $HOME, $PWD, $OLDPWD)
636 tree [-L depth] - Display directory tree (use -L to limit depth)
637 killall moles - Eliminate moles (when in the same directory)
638 score - Show current score and moles killed
639 help - Show this help message
640
641 Special paths:
642 ~ - Home directory
643 - - Previous directory
644 .. - Parent directory"""
645 response_data['success'] = True
646
647 else:
648 response_data['output'] = f"bash: {cmd}: command not found"
649
650 return Response(response_data)
651
652 @action(detail=True, methods=['get'])
653 def hint(self, request, pk=None):
654 """Get a hint about the mole's location"""
655 tree = self.get_object()
656
657 if not tree.mole_location:
658 return Response({'hint': 'No mole in this tree!'})
659
660 mole_depth = tree.mole_location.count('/')
661 player_depth = tree.player_location.count('/')
662
663 hints = []
664 if mole_depth > player_depth:
665 hints.append("The mole is deeper in the filesystem than you are.")
666 elif mole_depth < player_depth:
667 hints.append("The mole is in a shallower directory than you are.")
668 else:
669 hints.append("You're at the same depth as the mole!")
670
671 # Give a path hint
672 mole_parts = tree.mole_location.split('/')
673 player_parts = tree.player_location.split('/')
674
675 # Find common path
676 common_parts = []
677 for i, (m, p) in enumerate(zip(mole_parts, player_parts)):
678 if m == p:
679 common_parts.append(m)
680 else:
681 break
682
683 if len(common_parts) == len(mole_parts):
684 hints.append("You're in the mole's directory! Use 'killall moles'!")
685 elif len(common_parts) > 1:
686 hints.append(f"You share a common path with the mole: {'/'.join(common_parts) or '/'}")
687
688 return Response({'hints': hints})
689
690
691 class DirectoryNodeViewSet(viewsets.ReadOnlyModelViewSet):
692 """ViewSet for browsing directory nodes"""
693 queryset = DirectoryNode.objects.all()
694 serializer_class = DirectoryNodeSerializer
695
696 def get_queryset(self):
697 queryset = super().get_queryset()
698 tree_id = self.request.query_params.get('tree', None)
699 if tree_id:
700 queryset = queryset.filter(tree_id=tree_id)
701 return queryset
702
703
704 class GameSessionViewSet(viewsets.ModelViewSet):
705 """ViewSet for game sessions"""
706 queryset = GameSession.objects.all()
707 serializer_class = GameSessionSerializer
708
709 @action(detail=False, methods=['get'])
710 def leaderboard(self, request):
711 """Get the leaderboard of fastest completions"""
712 completed_sessions = GameSession.objects.filter(
713 completed_at__isnull=False
714 ).order_by('time_taken', 'commands_used')[:20]
715
716 serializer = self.get_serializer(completed_sessions, many=True)
717 return Response(serializer.data)