Python · 24600 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/alice", "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 # Create game session
161 player_name = request.data.get('player_name', 'Anonymous')
162 session = GameSession.objects.create(
163 tree=tree,
164 player_name=player_name
165 )
166
167 serializer = self.get_serializer(tree)
168 return Response({
169 'tree': serializer.data,
170 'session_id': session.id,
171 'mole_hint': f"The mole is hiding somewhere in the filesystem!",
172 'home_directory': tree.home_directory
173 }, status=status.HTTP_201_CREATED)
174
175 @action(detail=True, methods=['get'])
176 def current_directory(self, request, pk=None):
177 """Get current directory contents and player location"""
178 tree = self.get_object()
179
180 try:
181 current_dir = DirectoryNode.objects.get(
182 tree=tree,
183 path=tree.player_location
184 )
185 contents = current_dir.get_contents()
186
187 return Response({
188 'path': tree.player_location,
189 'contents': DirectoryNodeSerializer(contents, many=True).data,
190 'parent': current_dir.parent.path if current_dir.parent else None
191 })
192 except DirectoryNode.DoesNotExist:
193 return Response(
194 {'error': 'Current directory not found'},
195 status=status.HTTP_404_NOT_FOUND
196 )
197
198 @action(detail=True, methods=['post'])
199 def execute_command(self, request, pk=None):
200 """Execute a shell command in the game"""
201 tree = self.get_object()
202 command = request.data.get('command', '').strip()
203 session_id = request.data.get('session_id')
204
205 if not command:
206 return Response(
207 {'error': 'No command provided'},
208 status=status.HTTP_400_BAD_REQUEST
209 )
210
211 # Track commands for tree
212 tree.total_commands += 1
213 tree.save()
214
215 # Get session if provided
216 session = None
217 commands_before_mole = tree.total_commands # Track for scoring
218
219 if session_id:
220 try:
221 session = GameSession.objects.get(id=session_id, tree=tree)
222 session.add_command(command)
223 commands_before_mole = session.commands_used
224 except GameSession.DoesNotExist:
225 pass
226
227 # Parse and execute command
228 parts = command.split()
229 cmd = parts[0] if parts else ""
230
231 response_data = {
232 'command': command,
233 'success': False,
234 'output': '',
235 'current_path': tree.player_location,
236 'mole_spawned': False,
237 'mole_direction': None,
238 'score': 0
239 }
240
241 if cmd == 'cd':
242 if len(parts) < 2:
243 # cd with no args goes to home directory
244 success, message = tree.move_player("~")
245 response_data['success'] = success
246 response_data['output'] = message if not success else ""
247 response_data['current_path'] = tree.player_location
248 else:
249 target = parts[1]
250 success, message = tree.move_player(target)
251 response_data['success'] = success
252 response_data['output'] = message
253 response_data['current_path'] = tree.player_location
254
255 if success and session:
256 session.directories_visited += 1
257 session.save()
258
259 elif cmd == 'pushd':
260 if len(parts) < 2:
261 # pushd with no args swaps top two directories on stack
262 if tree.directory_stack:
263 success, message = tree.push_directory()
264 response_data['success'] = success
265 response_data['output'] = message
266 else:
267 response_data['output'] = "pushd: no other directory"
268 else:
269 target = parts[1]
270 success, message = tree.push_directory(target)
271 response_data['success'] = success
272 response_data['output'] = message
273 response_data['current_path'] = tree.player_location
274
275 if success and session:
276 session.directories_visited += 1
277 session.save()
278
279 elif cmd == 'popd':
280 success, message = tree.pop_directory()
281 response_data['success'] = success
282 response_data['output'] = message
283 response_data['current_path'] = tree.player_location
284
285 if success and session:
286 session.directories_visited += 1
287 session.save()
288
289 elif cmd == 'dirs':
290 # Show directory stack
291 stack = tree.get_directory_stack()
292 if stack:
293 response_data['output'] = ' '.join(stack)
294 else:
295 response_data['output'] = tree.player_location
296 response_data['success'] = True
297
298 elif cmd == 'ls':
299 # Handle ls with options
300 show_all = '-a' in parts or '-la' in parts or '-al' in parts
301 long_format = '-l' in parts or '-la' in parts or '-al' in parts
302
303 try:
304 current_dir = DirectoryNode.objects.get(
305 tree=tree,
306 path=tree.player_location
307 )
308 contents = current_dir.get_contents()
309
310 output_lines = []
311
312 if show_all:
313 # Add . and .. entries
314 if long_format:
315 output_lines.append("drwxr-xr-x . " + current_dir.description)
316 if current_dir.parent:
317 output_lines.append("drwxr-xr-x .. " + current_dir.parent.description)
318 else:
319 output_lines.extend(['.', '..'])
320
321 if contents:
322 for d in contents:
323 if long_format:
324 output_lines.append(f"drwxr-xr-x {d.name} {d.description}")
325 else:
326 output_lines.append(d.name)
327
328 if long_format:
329 response_data['output'] = '\n'.join(output_lines)
330 else:
331 # Format in columns for regular ls
332 if output_lines:
333 response_data['output'] = ' '.join(output_lines)
334 else:
335 response_data['output'] = ''
336
337 response_data['success'] = True
338 except DirectoryNode.DoesNotExist:
339 response_data['output'] = "ls: cannot access directory"
340
341 elif cmd == 'pwd':
342 response_data['output'] = tree.player_location
343 response_data['success'] = True
344
345 elif cmd == 'echo':
346 # Simple echo implementation
347 if len(parts) > 1:
348 # Handle special variables
349 echo_text = ' '.join(parts[1:])
350 if echo_text == '$HOME':
351 response_data['output'] = tree.home_directory
352 elif echo_text == '$PWD':
353 response_data['output'] = tree.player_location
354 elif echo_text == '$OLDPWD':
355 response_data['output'] = tree.previous_location or ''
356 else:
357 response_data['output'] = echo_text
358 else:
359 response_data['output'] = ''
360 response_data['success'] = True
361
362 elif cmd == 'tree':
363 # Parse options
364 show_all = '-a' in parts
365 max_depth = 3 # Default depth
366
367 # Check for -L option
368 if '-L' in parts:
369 try:
370 depth_index = parts.index('-L') + 1
371 if depth_index < len(parts):
372 max_depth = int(parts[depth_index])
373 max_depth = min(max(max_depth, 1), 5) # Limit between 1-5
374 except (ValueError, IndexError):
375 pass
376
377 try:
378 current_dir = DirectoryNode.objects.get(
379 tree=tree,
380 path=tree.player_location
381 )
382
383 # Build tree output
384 output_lines = [tree.player_location]
385
386 def build_tree_output(node, prefix="", is_last=True, depth=0):
387 if depth >= max_depth:
388 return
389
390 children = list(node.get_contents().order_by('name'))
391
392 for i, child in enumerate(children):
393 is_last_child = i == len(children) - 1
394
395 # Determine if this directory contains the mole
396 has_mole_in_subtree = False
397 if child.path == tree.mole_location:
398 has_mole_in_subtree = True
399 else:
400 # Check if mole is in any subdirectory
401 all_descendants = DirectoryNode.objects.filter(
402 tree=tree,
403 path__startswith=child.path + '/'
404 )
405 if any(d.path == tree.mole_location for d in all_descendants):
406 has_mole_in_subtree = True
407
408 # Build the tree branch characters
409 connector = "└── " if is_last_child else "├── "
410
411 # Format the directory name
412 if has_mole_in_subtree:
413 # Use X to indicate mole presence
414 dir_display = f"[X] {child.name}"
415 else:
416 dir_display = child.name
417
418 output_lines.append(f"{prefix}{connector}{dir_display}")
419
420 # Recurse into subdirectories
421 if depth + 1 < max_depth:
422 extension = " " if is_last_child else "│ "
423 build_tree_output(child, prefix + extension, is_last_child, depth + 1)
424
425 build_tree_output(current_dir)
426
427 # Add directory count at the end
428 total_shown = len(output_lines) - 1
429 if total_shown == 0:
430 output_lines.append("\n0 directories")
431 else:
432 dir_text = "directory" if total_shown == 1 else "directories"
433 output_lines.append(f"\n{total_shown} {dir_text}")
434
435 response_data['output'] = '\n'.join(output_lines)
436 response_data['success'] = True
437
438 except DirectoryNode.DoesNotExist:
439 response_data['output'] = "tree: cannot access directory"
440
441 elif cmd == 'killall' and len(parts) > 1 and parts[1] == 'moles':
442 if tree.check_win_condition():
443 # Track old mole location for stats
444 old_mole_location = tree.mole_location
445
446 # Update mole kill count
447 tree.moles_killed += 1
448 tree.save()
449
450 # Record stats for session
451 if session:
452 # Calculate commands used for this mole
453 commands_for_mole = session.commands_used - commands_before_mole + 1 # +1 for killall
454
455 # Calculate time (simplified for now - would need to track per-mole start time)
456 time_for_mole = timezone.now() - session.started_at
457
458 # Calculate distance traveled (simplified - just the path distance)
459 distance = tree.calculate_path_distance(tree.home_directory, old_mole_location)
460
461 session.record_mole_kill(
462 old_mole_location,
463 commands_for_mole,
464 time_for_mole,
465 distance
466 )
467 response_data['score'] = session.calculate_score()
468
469 # Spawn new mole
470 if tree.spawn_new_mole():
471 mole_direction = tree.get_mole_direction()
472
473 response_data['output'] = f"🎉 You eliminated the mole! (Total moles killed: {tree.moles_killed})\n🐭 A new mole has appeared somewhere in the filesystem!"
474 response_data['success'] = True
475 response_data['mole_spawned'] = True
476 response_data['mole_direction'] = mole_direction
477 response_data['moles_killed'] = tree.moles_killed
478 response_data['new_mole_location'] = tree.mole_location # Add this!
479 else:
480 response_data['output'] = "🎉 You eliminated the mole! Unable to spawn new mole."
481 response_data['success'] = True
482 else:
483 response_data['output'] = "No moles found in this directory."
484 response_data['success'] = True
485
486 elif cmd == 'score':
487 # New command to check current score
488 if session:
489 response_data['output'] = f"Score: {session.calculate_score()} | Moles killed: {session.moles_killed}"
490 response_data['score'] = session.calculate_score()
491 else:
492 response_data['output'] = "No active session to score."
493 response_data['success'] = True
494
495 elif cmd == 'help':
496 response_data['output'] = """Available commands:
497 cd <directory> - Change directory (supports ~, -, and ..)
498 cd - Go to home directory
499 pushd <directory> - Push directory onto stack and change to it
500 popd - Pop directory from stack and change to it
501 dirs - Display directory stack
502 ls [-la] - List directory contents
503 pwd - Print working directory
504 echo <text> - Display text (supports $HOME, $PWD, $OLDPWD)
505 tree [-L depth] - Display directory tree (use -L to limit depth)
506 killall moles - Eliminate moles (when in the same directory)
507 score - Show current score and moles killed
508 help - Show this help message
509
510 Special paths:
511 ~ - Home directory
512 - - Previous directory
513 .. - Parent directory"""
514 response_data['success'] = True
515
516 else:
517 response_data['output'] = f"bash: {cmd}: command not found"
518
519 return Response(response_data)
520
521 @action(detail=True, methods=['get'])
522 def hint(self, request, pk=None):
523 """Get a hint about the mole's location"""
524 tree = self.get_object()
525
526 if not tree.mole_location:
527 return Response({'hint': 'No mole in this tree!'})
528
529 mole_depth = tree.mole_location.count('/')
530 player_depth = tree.player_location.count('/')
531
532 hints = []
533 if mole_depth > player_depth:
534 hints.append("The mole is deeper in the filesystem than you are.")
535 elif mole_depth < player_depth:
536 hints.append("The mole is in a shallower directory than you are.")
537 else:
538 hints.append("You're at the same depth as the mole!")
539
540 # Give a path hint
541 mole_parts = tree.mole_location.split('/')
542 player_parts = tree.player_location.split('/')
543
544 # Find common path
545 common_parts = []
546 for i, (m, p) in enumerate(zip(mole_parts, player_parts)):
547 if m == p:
548 common_parts.append(m)
549 else:
550 break
551
552 if len(common_parts) == len(mole_parts):
553 hints.append("You're in the mole's directory! Use 'killall moles'!")
554 elif len(common_parts) > 1:
555 hints.append(f"You share a common path with the mole: {'/'.join(common_parts) or '/'}")
556
557 return Response({'hints': hints})
558
559
560 class DirectoryNodeViewSet(viewsets.ReadOnlyModelViewSet):
561 """ViewSet for browsing directory nodes"""
562 queryset = DirectoryNode.objects.all()
563 serializer_class = DirectoryNodeSerializer
564
565 def get_queryset(self):
566 queryset = super().get_queryset()
567 tree_id = self.request.query_params.get('tree', None)
568 if tree_id:
569 queryset = queryset.filter(tree_id=tree_id)
570 return queryset
571
572
573 class GameSessionViewSet(viewsets.ModelViewSet):
574 """ViewSet for game sessions"""
575 queryset = GameSession.objects.all()
576 serializer_class = GameSessionSerializer
577
578 @action(detail=False, methods=['get'])
579 def leaderboard(self, request):
580 """Get the leaderboard of fastest completions"""
581 completed_sessions = GameSession.objects.filter(
582 completed_at__isnull=False
583 ).order_by('time_taken', 'commands_used')[:20]
584
585 serializer = self.get_serializer(completed_sessions, many=True)
586 return Response(serializer.data)