Python · 23446 bytes Raw Blame History
1 # apps/trees/models.py
2 from django.db import models
3 from django.utils import timezone
4 import json
5 import random
6 import string
7 import math
8
9 class FileSystemTree(models.Model):
10 """A complete filesystem tree for a game session"""
11 name = models.CharField(max_length=100, default="FHS Tree")
12 created_at = models.DateTimeField(auto_now_add=True)
13 seed = models.IntegerField(default=0)
14
15 # Game state
16 mole_location = models.CharField(max_length=500, blank=True) # Path to mole
17 player_location = models.CharField(max_length=500, default="/home")
18 is_completed = models.BooleanField(default=False)
19 completed_at = models.DateTimeField(null=True, blank=True)
20
21 # Navigation state
22 previous_location = models.CharField(max_length=500, default="") # For cd -
23 directory_stack = models.JSONField(default=list) # For pushd/popd
24 home_directory = models.CharField(max_length=500, default="/home") # Player's home
25
26 # Game statistics
27 moles_killed = models.IntegerField(default=0)
28 total_commands = models.IntegerField(default=0)
29 total_directories_visited = models.IntegerField(default=0)
30
31 # Cached tree structure
32 tree_data = models.JSONField(null=True, blank=True)
33
34 def __str__(self):
35 return f"{self.name} - {'Completed' if self.is_completed else 'Active'}"
36
37 def generate_tree(self, max_depth=5, directories_per_level=3):
38 """Generate a procedural Unix filesystem tree"""
39 if self.seed == 0:
40 self.seed = random.randint(1, 1000000)
41
42 random.seed(self.seed)
43
44 # Clear existing nodes
45 self.nodes.all().delete()
46
47 # Create root
48 root = DirectoryNode.objects.create(
49 tree=self,
50 name="",
51 path="/",
52 parent=None,
53 is_fhs_standard=True,
54 description="Root directory"
55 )
56
57 # Create standard FHS directories
58 self._create_fhs_structure(root)
59
60 # Add procedural directories to some locations
61 self._add_procedural_directories(max_depth, directories_per_level)
62
63 # Place the mole in a random directory (not in standard FHS locations)
64 self._place_mole()
65
66 # Set random starting position for player
67 self._set_random_start_position()
68
69 # Cache the tree structure
70 self.cache_tree()
71 self.save()
72
73 def _create_fhs_structure(self, root):
74 """Create standard FHS directory structure"""
75 fhs_dirs = [
76 {"name": "bin", "desc": "Essential command binaries"},
77 {"name": "boot", "desc": "Static files of the boot loader"},
78 {"name": "dev", "desc": "Device files"},
79 {"name": "etc", "desc": "Host-specific system configuration"},
80 {"name": "home", "desc": "User home directories"},
81 {"name": "lib", "desc": "Essential shared libraries and kernel modules"},
82 {"name": "media", "desc": "Mount points for removable media"},
83 {"name": "mnt", "desc": "Mount point for temporarily mounted filesystems"},
84 {"name": "opt", "desc": "Add-on application software packages"},
85 {"name": "proc", "desc": "Virtual filesystem for process information"},
86 {"name": "root", "desc": "Home directory for the root user"},
87 {"name": "run", "desc": "Data relevant to running processes"},
88 {"name": "sbin", "desc": "Essential system binaries"},
89 {"name": "srv", "desc": "Data for services provided by this system"},
90 {"name": "sys", "desc": "Virtual filesystem for system information"},
91 {"name": "tmp", "desc": "Temporary files"},
92 {"name": "usr", "desc": "Secondary hierarchy"},
93 {"name": "var", "desc": "Variable data"},
94 ]
95
96 for dir_info in fhs_dirs:
97 DirectoryNode.objects.create(
98 tree=self,
99 name=dir_info["name"],
100 path=f"/{dir_info['name']}",
101 parent=root,
102 is_fhs_standard=True,
103 description=dir_info["desc"]
104 )
105
106 # Create some standard subdirectories
107 usr = DirectoryNode.objects.get(tree=self, path="/usr")
108 for subdir in ["bin", "lib", "local", "share", "src"]:
109 DirectoryNode.objects.create(
110 tree=self,
111 name=subdir,
112 path=f"/usr/{subdir}",
113 parent=usr,
114 is_fhs_standard=True,
115 description=f"User {subdir} directory"
116 )
117
118 # Create user directories
119 home = DirectoryNode.objects.get(tree=self, path="/home")
120 for username in ["sarah", "bob", "charlie"]:
121 user_home = DirectoryNode.objects.create(
122 tree=self,
123 name=username,
124 path=f"/home/{username}",
125 parent=home,
126 is_fhs_standard=False,
127 description=f"Home directory for {username}"
128 )
129
130 # Add some standard user directories
131 for userdir in ["Documents", "Downloads", "Desktop", "Pictures"]:
132 DirectoryNode.objects.create(
133 tree=self,
134 name=userdir,
135 path=f"/home/{username}/{userdir}",
136 parent=user_home,
137 is_fhs_standard=False,
138 description=f"{username}'s {userdir}"
139 )
140
141 def _add_procedural_directories(self, max_depth, dirs_per_level):
142 """Add procedurally generated directories to make the tree interesting"""
143 # Common directory names for procedural generation
144 dir_names = [
145 "projects", "workspace", "temp", "backup", "archive", "data",
146 "config", "logs", "cache", "build", "dist", "assets",
147 "scripts", "tools", "utils", "resources", "public", "private",
148 "old", "new", "test", "prod", "dev", "staging",
149 "alpha", "beta", "gamma", "delta", "epsilon", "zeta",
150 "red", "blue", "green", "yellow", "purple", "orange",
151 "cat", "dog", "fish", "bird", "mouse", "rabbit"
152 ]
153
154 # Add procedural dirs to certain locations
155 base_paths = [
156 "/home/sarah", "/home/bob", "/home/charlie",
157 "/opt", "/var", "/usr/local"
158 ]
159
160 for base_path in base_paths:
161 try:
162 base_node = DirectoryNode.objects.get(tree=self, path=base_path)
163 self._generate_subtree(base_node, max_depth-2, dirs_per_level, dir_names)
164 except DirectoryNode.DoesNotExist:
165 continue
166
167 def _generate_subtree(self, parent, depth, dirs_per_level, name_pool):
168 """Recursively generate random subdirectories"""
169 if depth <= 0:
170 return
171
172 # Random number of directories at this level
173 num_dirs = random.randint(1, dirs_per_level)
174 used_names = set()
175
176 for _ in range(num_dirs):
177 # Pick a unique name for this level
178 name = random.choice(name_pool)
179 while name in used_names:
180 name = random.choice(name_pool)
181 used_names.add(name)
182
183 # Create the directory
184 path = f"{parent.path}/{name}" if parent.path != "/" else f"/{name}"
185 new_dir = DirectoryNode.objects.create(
186 tree=self,
187 name=name,
188 path=path,
189 parent=parent,
190 is_fhs_standard=False,
191 description=f"Procedurally generated directory"
192 )
193
194 # Randomly decide whether to create subdirectories
195 if random.random() > 0.3: # 70% chance of subdirectories
196 self._generate_subtree(new_dir, depth-1, dirs_per_level, name_pool)
197
198 def _place_mole(self):
199 """Place the mole in a random non-FHS directory"""
200 candidates = DirectoryNode.objects.filter(
201 tree=self,
202 is_fhs_standard=False
203 ).exclude(path__in=[
204 "/home", "/home/sarah", "/home/bob", "/home/charlie"
205 ])
206
207 if candidates.exists():
208 mole_dir = random.choice(candidates)
209 self.mole_location = mole_dir.path
210
211 def spawn_new_mole(self):
212 """Spawn a new mole after the current one is killed"""
213 # Get all possible spawn locations (any directory)
214 all_directories = DirectoryNode.objects.filter(tree=self).exclude(path="/")
215
216 if all_directories.exists():
217 # Randomly select a new location
218 new_mole_dir = random.choice(all_directories)
219 self.mole_location = new_mole_dir.path
220
221 # Update the cached tree data
222 self.cache_tree()
223 self.save()
224
225 return True
226 return False
227
228 def get_mole_direction(self):
229 """Get the relative direction from player to mole in the tree structure"""
230 if not self.mole_location or not self.player_location:
231 return None
232
233 # Build paths to compare
234 player_parts = self.player_location.split('/')
235 mole_parts = self.mole_location.split('/')
236
237 # Remove empty strings from split
238 player_parts = [p for p in player_parts if p]
239 mole_parts = [p for p in mole_parts if p]
240
241 # If player is at root, special handling
242 if not player_parts:
243 player_parts = ['']
244
245 # Find common ancestor
246 common_depth = 0
247 for i in range(min(len(player_parts), len(mole_parts))):
248 if player_parts[i] == mole_parts[i]:
249 common_depth += 1
250 else:
251 break
252
253 # Determine relative position
254 player_depth = len(player_parts)
255 mole_depth = len(mole_parts)
256
257 # Calculate tree-based direction
258 if self.mole_location == self.player_location:
259 return {"direction": "here", "angle": 0}
260
261 # Mole is in a parent directory (need to go up)
262 if common_depth == mole_depth and mole_depth < player_depth:
263 return {"direction": "up", "angle": 270} # Up in tree
264
265 # Mole is in a child directory (need to go down)
266 if common_depth == player_depth and mole_depth > player_depth:
267 # It's directly below us
268 return {"direction": "down", "angle": 90} # Down in tree
269
270 # Mole is in a sibling or cousin branch
271 if common_depth < player_depth:
272 # Need to go up first, then sideways
273 # Determine left or right based on alphabetical order of diverging paths
274 if common_depth < len(mole_parts) and common_depth < len(player_parts):
275 if mole_parts[common_depth] < player_parts[common_depth]:
276 return {"direction": "up-left", "angle": 225}
277 else:
278 return {"direction": "up-right", "angle": 315}
279 return {"direction": "up", "angle": 270}
280 else:
281 # At same level or need to go down and sideways
282 if common_depth < len(mole_parts):
283 # Determine left or right based on tree structure
284 # This is a simplification - in reality we'd need to check the actual tree layout
285 if mole_parts[common_depth] < (player_parts[common_depth] if common_depth < len(player_parts) else 'z'):
286 return {"direction": "left", "angle": 180}
287 else:
288 return {"direction": "right", "angle": 0}
289 return {"direction": "down", "angle": 90}
290
291 def calculate_path_distance(self, from_path, to_path):
292 """Calculate the minimum number of cd commands needed to go from one path to another"""
293 if from_path == to_path:
294 return 0
295
296 from_parts = from_path.split('/')
297 to_parts = to_path.split('/')
298
299 # Remove empty strings
300 from_parts = [p for p in from_parts if p]
301 to_parts = [p for p in to_parts if p]
302
303 # Find common ancestor depth
304 common_depth = 0
305 for i in range(min(len(from_parts), len(to_parts))):
306 if from_parts[i] == to_parts[i]:
307 common_depth += 1
308 else:
309 break
310
311 # Calculate moves needed
312 moves_up = len(from_parts) - common_depth
313 moves_down = len(to_parts) - common_depth
314
315 return moves_up + moves_down
316
317 def _set_random_start_position(self):
318 """Set a random starting position for the player"""
319 # Get all directories that could be valid starting positions
320 # Exclude root and very deep directories (depth > 3)
321 candidates = DirectoryNode.objects.filter(
322 tree=self
323 ).exclude(
324 path="/" # Don't start at root
325 )
326
327 # Filter to reasonable starting positions
328 valid_starts = []
329 for node in candidates:
330 depth = node.path.count('/')
331 # Prefer directories at depth 1-3
332 if 1 <= depth <= 3:
333 # Don't start at the mole location
334 if node.path != self.mole_location:
335 valid_starts.append(node)
336
337 if valid_starts:
338 # Weight selection towards common starting areas but allow anywhere
339 weights = []
340 for node in valid_starts:
341 if node.path.startswith('/home'):
342 weights.append(3) # Higher weight for home directories
343 elif node.path.startswith('/usr'):
344 weights.append(2) # Medium weight for usr directories
345 else:
346 weights.append(1) # Lower weight for other directories
347
348 # Select random starting position with weights
349 start_node = random.choices(valid_starts, weights=weights, k=1)[0]
350 self.player_location = start_node.path
351
352 # Set home directory based on starting location
353 if start_node.path.startswith('/home/'):
354 # Extract the user's home directory
355 parts = start_node.path.split('/')
356 if len(parts) >= 3:
357 self.home_directory = f"/home/{parts[2]}"
358 else:
359 self.home_directory = "/home"
360 else:
361 # Default home directory
362 self.home_directory = "/home"
363 else:
364 # Fallback to /home if no valid candidates
365 self.player_location = "/home"
366 self.home_directory = "/home"
367
368 def cache_tree(self):
369 """Cache the tree structure for efficient retrieval"""
370 def build_tree_dict(node):
371 children = DirectoryNode.objects.filter(parent=node)
372 return {
373 "name": node.name,
374 "path": node.path,
375 "is_fhs": node.is_fhs_standard,
376 "description": node.description,
377 "has_mole": node.path == self.mole_location,
378 "children": [build_tree_dict(child) for child in children]
379 }
380
381 root = DirectoryNode.objects.get(tree=self, path="/")
382 self.tree_data = build_tree_dict(root)
383
384 def resolve_path(self, path):
385 """Resolve a path that may contain ~ or be relative"""
386 if path == "~":
387 return self.home_directory
388 elif path.startswith("~/"):
389 return self.home_directory + path[1:]
390 elif path == "-":
391 return self.previous_location if self.previous_location else self.player_location
392 elif not path.startswith('/'):
393 # Relative path
394 if self.player_location == "/":
395 return "/" + path
396 else:
397 return self.player_location + "/" + path
398 else:
399 # Absolute path
400 return path
401
402 def normalize_path(self, path):
403 """Normalize a path by resolving .. and . components"""
404 parts = path.split('/')
405 resolved = []
406
407 for part in parts:
408 if part == '' and len(resolved) == 0:
409 # Leading slash
410 resolved.append('')
411 elif part == '..':
412 if len(resolved) > 1:
413 resolved.pop()
414 elif part != '.' and part != '':
415 resolved.append(part)
416
417 if len(resolved) == 1 and resolved[0] == '':
418 return '/'
419 return '/'.join(resolved)
420
421 def move_player(self, target_path):
422 """Move player to a new location if valid"""
423 # Resolve special path symbols
424 resolved_path = self.resolve_path(target_path)
425
426 # Handle ".." in the path
427 if ".." in resolved_path or resolved_path == "..":
428 if resolved_path == "..":
429 # Go up one directory from current location
430 if self.player_location == "/":
431 return False, "Already at root directory"
432 new_path = "/".join(self.player_location.split("/")[:-1]) or "/"
433 else:
434 # Normalize the path to handle .. components
435 new_path = self.normalize_path(resolved_path)
436 else:
437 new_path = resolved_path
438
439 # Check if the directory exists
440 if DirectoryNode.objects.filter(tree=self, path=new_path).exists():
441 # Save previous location before moving
442 self.previous_location = self.player_location
443 self.player_location = new_path
444 self.total_directories_visited += 1
445 self.save()
446 return True, f"Moved to {self.player_location}"
447 else:
448 return False, f"Directory not found: {target_path}"
449
450 def push_directory(self, target_path=None):
451 """Push current directory onto stack and optionally change to new directory"""
452 # Add current directory to stack
453 if not isinstance(self.directory_stack, list):
454 self.directory_stack = []
455
456 self.directory_stack.append(self.player_location)
457
458 # If target path provided, change to it
459 if target_path:
460 success, message = self.move_player(target_path)
461 if success:
462 self.save()
463 return True, f"Pushed {self.directory_stack[-1]} and moved to {self.player_location}"
464 else:
465 # Remove from stack if move failed
466 self.directory_stack.pop()
467 return False, message
468 else:
469 self.save()
470 return True, f"Pushed {self.player_location} onto directory stack"
471
472 def pop_directory(self):
473 """Pop directory from stack and change to it"""
474 if not self.directory_stack:
475 return False, "Directory stack is empty"
476
477 # Get directory from stack
478 target_dir = self.directory_stack.pop()
479
480 # Save current location as previous (for cd -)
481 self.previous_location = self.player_location
482 self.player_location = target_dir
483 self.total_directories_visited += 1
484 self.save()
485
486 return True, f"Popped and moved to {self.player_location}"
487
488 def get_directory_stack(self):
489 """Get the current directory stack for display"""
490 if not self.directory_stack:
491 return []
492 return list(self.directory_stack) + [self.player_location]
493
494 def check_win_condition(self):
495 """Check if player is in the same directory as the mole"""
496 return self.player_location == self.mole_location
497
498
499 class DirectoryNode(models.Model):
500 """A directory in the filesystem tree"""
501 tree = models.ForeignKey(FileSystemTree, on_delete=models.CASCADE, related_name='nodes')
502 parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='children')
503
504 name = models.CharField(max_length=100)
505 path = models.CharField(max_length=500, db_index=True)
506 is_fhs_standard = models.BooleanField(default=False)
507 description = models.TextField(blank=True)
508
509 class Meta:
510 unique_together = ('tree', 'path')
511 ordering = ['path']
512
513 def __str__(self):
514 return f"{self.path} ({'FHS' if self.is_fhs_standard else 'Generated'})"
515
516 @property
517 def depth(self):
518 """Calculate directory depth"""
519 return self.path.count('/')
520
521 def get_contents(self):
522 """Get immediate children of this directory"""
523 return DirectoryNode.objects.filter(parent=self).order_by('name')
524
525
526 class GameSession(models.Model):
527 """Track game sessions and scores"""
528 tree = models.ForeignKey(FileSystemTree, on_delete=models.CASCADE, related_name='sessions')
529 player_name = models.CharField(max_length=100, default="Anonymous")
530 started_at = models.DateTimeField(auto_now_add=True)
531 completed_at = models.DateTimeField(null=True, blank=True)
532
533 # Game metrics
534 commands_used = models.IntegerField(default=0)
535 directories_visited = models.IntegerField(default=0)
536 time_taken = models.DurationField(null=True, blank=True)
537 moles_killed = models.IntegerField(default=0)
538
539 # Per-mole tracking
540 mole_stats = models.JSONField(default=list) # List of {mole_number, location, commands, time, distance}
541
542 # Command history
543 command_history = models.JSONField(default=list)
544
545 def __str__(self):
546 return f"{self.player_name} - {self.tree.name}"
547
548 def add_command(self, command):
549 """Add a command to the history"""
550 self.command_history.append({
551 'command': command,
552 'timestamp': str(timezone.now())
553 })
554 self.commands_used += 1
555 self.save()
556
557 def record_mole_kill(self, mole_location, commands_for_mole, time_for_mole, distance_traveled):
558 """Record statistics for a mole kill"""
559 self.moles_killed += 1
560 self.mole_stats.append({
561 'mole_number': self.moles_killed,
562 'location': mole_location,
563 'commands': commands_for_mole,
564 'time': str(time_for_mole),
565 'distance': distance_traveled
566 })
567 self.save()
568
569 def calculate_score(self):
570 """Calculate a score based on performance"""
571 if self.moles_killed == 0:
572 return 0
573
574 # Base score per mole
575 base_score = 1000
576
577 # Calculate average performance
578 total_commands = sum(stat['commands'] for stat in self.mole_stats)
579 avg_commands = total_commands / self.moles_killed if self.moles_killed > 0 else 0
580
581 # Score formula (rough draft)
582 # More moles = better
583 # Fewer commands = better
584 # Less time = better (when we implement timing)
585 score = self.moles_killed * base_score
586
587 # Efficiency bonus (fewer commands is better)
588 if avg_commands > 0:
589 efficiency_multiplier = max(0.5, min(2.0, 10 / avg_commands))
590 score *= efficiency_multiplier
591
592 return int(score)