Python · 28903 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 # Timer configuration
32 default_mole_timer = models.IntegerField(default=60) # seconds (will be calculated dynamically)
33 current_mole_timer = models.IntegerField(default=60) # seconds remaining
34
35 # Mole spawn timestamp
36 mole_spawned_at = models.DateTimeField(null=True, blank=True)
37
38 # Timer state
39 timer_paused = models.BooleanField(default=False)
40 timer_expired_count = models.IntegerField(default=0) # Track escapes
41
42 # Cached tree structure
43 tree_data = models.JSONField(null=True, blank=True)
44
45 def __str__(self):
46 return f"{self.name} - {'Completed' if self.is_completed else 'Active'}"
47
48 def generate_tree(self, max_depth=5, directories_per_level=3):
49 """Generate a procedural Unix filesystem tree"""
50 if self.seed == 0:
51 self.seed = random.randint(1, 1000000)
52
53 random.seed(self.seed)
54
55 # Clear existing nodes
56 self.nodes.all().delete()
57
58 # Create root
59 root = DirectoryNode.objects.create(
60 tree=self,
61 name="",
62 path="/",
63 parent=None,
64 is_fhs_standard=True,
65 description="Root directory"
66 )
67
68 # Create standard FHS directories
69 self._create_fhs_structure(root)
70
71 # Add procedural directories to some locations
72 self._add_procedural_directories(max_depth, directories_per_level)
73
74 # Place the mole in a random directory (not in standard FHS locations)
75 self._place_mole()
76
77 # Set random starting position for player
78 self._set_random_start_position()
79
80 # Cache the tree structure
81 self.cache_tree()
82 self.save()
83
84 def _create_fhs_structure(self, root):
85 """Create standard FHS directory structure"""
86 fhs_dirs = [
87 {"name": "bin", "desc": "Essential command binaries"},
88 {"name": "boot", "desc": "Static files of the boot loader"},
89 {"name": "dev", "desc": "Device files"},
90 {"name": "etc", "desc": "Host-specific system configuration"},
91 {"name": "home", "desc": "User home directories"},
92 {"name": "lib", "desc": "Essential shared libraries and kernel modules"},
93 {"name": "media", "desc": "Mount points for removable media"},
94 {"name": "mnt", "desc": "Mount point for temporarily mounted filesystems"},
95 {"name": "opt", "desc": "Add-on application software packages"},
96 {"name": "proc", "desc": "Virtual filesystem for process information"},
97 {"name": "root", "desc": "Home directory for the root user"},
98 {"name": "run", "desc": "Data relevant to running processes"},
99 {"name": "sbin", "desc": "Essential system binaries"},
100 {"name": "srv", "desc": "Data for services provided by this system"},
101 {"name": "sys", "desc": "Virtual filesystem for system information"},
102 {"name": "tmp", "desc": "Temporary files"},
103 {"name": "usr", "desc": "Secondary hierarchy"},
104 {"name": "var", "desc": "Variable data"},
105 ]
106
107 for dir_info in fhs_dirs:
108 DirectoryNode.objects.create(
109 tree=self,
110 name=dir_info["name"],
111 path=f"/{dir_info['name']}",
112 parent=root,
113 is_fhs_standard=True,
114 description=dir_info["desc"]
115 )
116
117 # Create some standard subdirectories
118 usr = DirectoryNode.objects.get(tree=self, path="/usr")
119 for subdir in ["bin", "lib", "local", "share", "src"]:
120 DirectoryNode.objects.create(
121 tree=self,
122 name=subdir,
123 path=f"/usr/{subdir}",
124 parent=usr,
125 is_fhs_standard=True,
126 description=f"User {subdir} directory"
127 )
128
129 # Create user directories
130 home = DirectoryNode.objects.get(tree=self, path="/home")
131 for username in ["sarah", "josh", "jules"]:
132 user_home = DirectoryNode.objects.create(
133 tree=self,
134 name=username,
135 path=f"/home/{username}",
136 parent=home,
137 is_fhs_standard=False,
138 description=f"Home directory for {username}"
139 )
140
141 # Add some standard user directories
142 for userdir in ["Documents", "Downloads", "Desktop", "Pictures"]:
143 DirectoryNode.objects.create(
144 tree=self,
145 name=userdir,
146 path=f"/home/{username}/{userdir}",
147 parent=user_home,
148 is_fhs_standard=False,
149 description=f"{username}'s {userdir}"
150 )
151
152 def _add_procedural_directories(self, max_depth, dirs_per_level):
153 """Add procedurally generated directories to make the tree interesting"""
154 # Common directory names for procedural generation
155 dir_names = [
156 "projects", "workspace", "temp", "backup", "archive", "data",
157 "config", "logs", "cache", "build", "dist", "assets",
158 "scripts", "tools", "utils", "resources", "public", "private",
159 "old", "new", "test", "prod", "dev", "staging",
160 "alpha", "beta", "gamma", "delta", "epsilon", "zeta",
161 "red", "blue", "green", "yellow", "purple", "orange",
162 "cat", "dog", "fish", "bird", "mouse", "rabbit"
163 ]
164
165 # Add procedural dirs to certain locations
166 base_paths = [
167 "/home/sarah", "/home/josh", "/home/jules",
168 "/opt", "/var", "/usr/local"
169 ]
170
171 for base_path in base_paths:
172 try:
173 base_node = DirectoryNode.objects.get(tree=self, path=base_path)
174 self._generate_subtree(base_node, max_depth-2, dirs_per_level, dir_names)
175 except DirectoryNode.DoesNotExist:
176 continue
177
178 def _generate_subtree(self, parent, depth, dirs_per_level, name_pool):
179 """Recursively generate random subdirectories"""
180 if depth <= 0:
181 return
182
183 # Random number of directories at this level
184 num_dirs = random.randint(1, dirs_per_level)
185 used_names = set()
186
187 for _ in range(num_dirs):
188 # Pick a unique name for this level
189 name = random.choice(name_pool)
190 while name in used_names:
191 name = random.choice(name_pool)
192 used_names.add(name)
193
194 # Create the directory
195 path = f"{parent.path}/{name}" if parent.path != "/" else f"/{name}"
196 new_dir = DirectoryNode.objects.create(
197 tree=self,
198 name=name,
199 path=path,
200 parent=parent,
201 is_fhs_standard=False,
202 description=f"Procedurally generated directory"
203 )
204
205 # Randomly decide whether to create subdirectories
206 if random.random() > 0.3: # 70% chance of subdirectories
207 self._generate_subtree(new_dir, depth-1, dirs_per_level, name_pool)
208
209 def _place_mole(self):
210 """Place the mole in a random non-FHS directory"""
211 candidates = DirectoryNode.objects.filter(
212 tree=self,
213 is_fhs_standard=False
214 ).exclude(path__in=[
215 "/home", "/home/sarah", "/home/josh", "/home/jules"
216 ])
217
218 if candidates.exists():
219 mole_dir = random.choice(candidates)
220 self.mole_location = mole_dir.path
221
222 # Calculate initial timer based on starting position
223 timer, reason, distance = self.calculate_mole_timer(
224 self.mole_location,
225 self.player_location if self.player_location else "/home"
226 )
227
228 # Set initial timer
229 self.mole_spawned_at = timezone.now()
230 self.default_mole_timer = timer
231 self.current_mole_timer = timer
232
233 def calculate_mole_timer(self, mole_path, player_path):
234 """Calculate timer based on distance between player and mole"""
235 distance = self.calculate_path_distance(player_path, mole_path)
236
237 # Base timer values based on distance - MORE AGGRESSIVE
238 if distance <= 1:
239 base_timer = 10 # Was 20 - Adjacent mole needs quick action!
240 timer_reason = "nearby"
241 elif distance <= 3:
242 base_timer = 20 # Was 30 - Still close, needs urgency
243 timer_reason = "close"
244 elif distance <= 5:
245 base_timer = 35 # Was 45 - Moderate distance
246 timer_reason = "moderate distance"
247 else:
248 base_timer = 50 # Was 60 - Far away
249 timer_reason = "far away"
250
251 # Apply difficulty progression (10% reduction per 5 moles, capped at 50% reduction)
252 difficulty_multiplier = 1.0
253 if self.moles_killed >= 5:
254 # Calculate 10% reduction per 5 moles
255 reductions = min(self.moles_killed // 5, 5) # Cap at 5 reductions (50%)
256 difficulty_multiplier = 1.0 - (0.1 * reductions)
257
258 # Calculate final timer
259 final_timer = int(base_timer * difficulty_multiplier)
260
261 # Ensure minimum timer of 10 seconds
262 final_timer = max(final_timer, 10)
263
264 return final_timer, timer_reason, distance
265
266 def spawn_new_mole(self):
267 """Spawn a new mole after the current one is killed or escapes"""
268 # Get all possible spawn locations (any directory)
269 all_directories = DirectoryNode.objects.filter(tree=self).exclude(path="/")
270
271 if all_directories.exists():
272 # Randomly select a new location
273 new_mole_dir = random.choice(all_directories)
274 self.mole_location = new_mole_dir.path
275
276 # Calculate smart timer based on distance
277 timer, reason, distance = self.calculate_mole_timer(
278 self.mole_location,
279 self.player_location
280 )
281
282 # Set timer for new mole
283 self.mole_spawned_at = timezone.now()
284 self.default_mole_timer = timer
285 self.current_mole_timer = timer
286 self.timer_paused = False
287
288 # Update the cached tree data
289 self.cache_tree()
290 self.save()
291
292 return True, timer, reason, distance
293 return False, None, None, None
294
295 def check_mole_timer(self):
296 """Check if mole timer has expired"""
297 if not self.mole_spawned_at or self.timer_paused:
298 return False, self.current_mole_timer
299
300 elapsed = (timezone.now() - self.mole_spawned_at).total_seconds()
301 remaining = max(0, self.default_mole_timer - elapsed)
302
303 if remaining <= 0:
304 # Mole escaped!
305 return True, 0
306
307 return False, int(remaining)
308
309 def handle_mole_escape(self):
310 """Handle when a mole escapes due to timer expiration"""
311 self.timer_expired_count += 1
312 old_location = self.mole_location
313
314 # Spawn new mole
315 success, timer, reason, distance = self.spawn_new_mole()
316 if success:
317 return {
318 'escaped': True,
319 'old_location': old_location,
320 'new_location': self.mole_location,
321 'total_escapes': self.timer_expired_count,
322 'new_timer': timer,
323 'timer_reason': reason,
324 'distance': distance
325 }
326 return None
327
328 def get_mole_direction(self):
329 """Get the relative direction from player to mole in the tree structure"""
330 if not self.mole_location or not self.player_location:
331 return None
332
333 # Build paths to compare
334 player_parts = self.player_location.split('/')
335 mole_parts = self.mole_location.split('/')
336
337 # Remove empty strings from split
338 player_parts = [p for p in player_parts if p]
339 mole_parts = [p for p in mole_parts if p]
340
341 # If player is at root, special handling
342 if not player_parts:
343 player_parts = ['']
344
345 # Find common ancestor
346 common_depth = 0
347 for i in range(min(len(player_parts), len(mole_parts))):
348 if player_parts[i] == mole_parts[i]:
349 common_depth += 1
350 else:
351 break
352
353 # Determine relative position
354 player_depth = len(player_parts)
355 mole_depth = len(mole_parts)
356
357 # Calculate tree-based direction
358 if self.mole_location == self.player_location:
359 return {"direction": "here", "angle": 0}
360
361 # Mole is in a parent directory (need to go up)
362 if common_depth == mole_depth and mole_depth < player_depth:
363 return {"direction": "up", "angle": 270} # Up in tree
364
365 # Mole is in a child directory (need to go down)
366 if common_depth == player_depth and mole_depth > player_depth:
367 # It's directly below us
368 return {"direction": "down", "angle": 90} # Down in tree
369
370 # Mole is in a sibling or cousin branch
371 if common_depth < player_depth:
372 # Need to go up first, then sideways
373 # Determine left or right based on alphabetical order of diverging paths
374 if common_depth < len(mole_parts) and common_depth < len(player_parts):
375 if mole_parts[common_depth] < player_parts[common_depth]:
376 return {"direction": "up-left", "angle": 225}
377 else:
378 return {"direction": "up-right", "angle": 315}
379 return {"direction": "up", "angle": 270}
380 else:
381 # At same level or need to go down and sideways
382 if common_depth < len(mole_parts):
383 # Determine left or right based on tree structure
384 # This is a simplification - in reality we'd need to check the actual tree layout
385 if mole_parts[common_depth] < (player_parts[common_depth] if common_depth < len(player_parts) else 'z'):
386 return {"direction": "left", "angle": 180}
387 else:
388 return {"direction": "right", "angle": 0}
389 return {"direction": "down", "angle": 90}
390
391 def calculate_path_distance(self, from_path, to_path):
392 """Calculate the minimum number of cd commands needed to go from one path to another"""
393 if from_path == to_path:
394 return 0
395
396 from_parts = from_path.split('/')
397 to_parts = to_path.split('/')
398
399 # Remove empty strings
400 from_parts = [p for p in from_parts if p]
401 to_parts = [p for p in to_parts if p]
402
403 # Find common ancestor depth
404 common_depth = 0
405 for i in range(min(len(from_parts), len(to_parts))):
406 if from_parts[i] == to_parts[i]:
407 common_depth += 1
408 else:
409 break
410
411 # Calculate moves needed
412 moves_up = len(from_parts) - common_depth
413 moves_down = len(to_parts) - common_depth
414
415 return moves_up + moves_down
416
417 def _set_random_start_position(self):
418 """Set a random starting position for the player"""
419 # Get all directories that could be valid starting positions
420 # Exclude root and very deep directories (depth > 3)
421 candidates = DirectoryNode.objects.filter(
422 tree=self
423 ).exclude(
424 path="/" # Don't start at root
425 )
426
427 # Filter to reasonable starting positions
428 valid_starts = []
429 for node in candidates:
430 depth = node.path.count('/')
431 # Prefer directories at depth 1-3
432 if 1 <= depth <= 3:
433 # Don't start at the mole location
434 if node.path != self.mole_location:
435 valid_starts.append(node)
436
437 if valid_starts:
438 # Weight selection towards common starting areas but allow anywhere
439 weights = []
440 for node in valid_starts:
441 if node.path.startswith('/home'):
442 weights.append(3) # Higher weight for home directories
443 elif node.path.startswith('/usr'):
444 weights.append(2) # Medium weight for usr directories
445 else:
446 weights.append(1) # Lower weight for other directories
447
448 # Select random starting position with weights
449 start_node = random.choices(valid_starts, weights=weights, k=1)[0]
450 self.player_location = start_node.path
451
452 # Set home directory based on starting location
453 if start_node.path.startswith('/home/'):
454 # Extract the user's home directory
455 parts = start_node.path.split('/')
456 if len(parts) >= 3:
457 self.home_directory = f"/home/{parts[2]}"
458 else:
459 self.home_directory = "/home"
460 else:
461 # Default home directory
462 self.home_directory = "/home"
463 else:
464 # Fallback to /home if no valid candidates
465 self.player_location = "/home"
466 self.home_directory = "/home"
467
468 def cache_tree(self):
469 """Cache the tree structure for efficient retrieval"""
470 def build_tree_dict(node):
471 children = DirectoryNode.objects.filter(parent=node)
472 return {
473 "name": node.name,
474 "path": node.path,
475 "is_fhs": node.is_fhs_standard,
476 "description": node.description,
477 "has_mole": node.path == self.mole_location,
478 "children": [build_tree_dict(child) for child in children]
479 }
480
481 root = DirectoryNode.objects.get(tree=self, path="/")
482 self.tree_data = build_tree_dict(root)
483
484 def resolve_path(self, path):
485 """Resolve a path that may contain ~ or be relative"""
486 if path == "~":
487 return self.home_directory
488 elif path.startswith("~/"):
489 return self.home_directory + path[1:]
490 elif path == "-":
491 return self.previous_location if self.previous_location else self.player_location
492 elif not path.startswith('/'):
493 # Relative path
494 if self.player_location == "/":
495 return "/" + path
496 else:
497 return self.player_location + "/" + path
498 else:
499 # Absolute path
500 return path
501
502 def normalize_path(self, path):
503 """Normalize a path by resolving .. and . components"""
504 parts = path.split('/')
505 resolved = []
506
507 for part in parts:
508 if part == '' and len(resolved) == 0:
509 # Leading slash
510 resolved.append('')
511 elif part == '..':
512 if len(resolved) > 1:
513 resolved.pop()
514 elif part != '.' and part != '':
515 resolved.append(part)
516
517 if len(resolved) == 1 and resolved[0] == '':
518 return '/'
519 return '/'.join(resolved)
520
521 def move_player(self, target_path):
522 """Move player to a new location if valid"""
523 # Resolve special path symbols
524 resolved_path = self.resolve_path(target_path)
525
526 # Handle ".." in the path
527 if ".." in resolved_path or resolved_path == "..":
528 if resolved_path == "..":
529 # Go up one directory from current location
530 if self.player_location == "/":
531 return False, "Already at root directory"
532 new_path = "/".join(self.player_location.split("/")[:-1]) or "/"
533 else:
534 # Normalize the path to handle .. components
535 new_path = self.normalize_path(resolved_path)
536 else:
537 new_path = resolved_path
538
539 # Check if the directory exists
540 if DirectoryNode.objects.filter(tree=self, path=new_path).exists():
541 # Save previous location before moving
542 self.previous_location = self.player_location
543 self.player_location = new_path
544 self.total_directories_visited += 1
545 self.save()
546 return True, f"Moved to {self.player_location}"
547 else:
548 return False, f"Directory not found: {target_path}"
549
550 def push_directory(self, target_path=None):
551 """Push current directory onto stack and optionally change to new directory"""
552 # Add current directory to stack
553 if not isinstance(self.directory_stack, list):
554 self.directory_stack = []
555
556 self.directory_stack.append(self.player_location)
557
558 # If target path provided, change to it
559 if target_path:
560 success, message = self.move_player(target_path)
561 if success:
562 self.save()
563 return True, f"Pushed {self.directory_stack[-1]} and moved to {self.player_location}"
564 else:
565 # Remove from stack if move failed
566 self.directory_stack.pop()
567 return False, message
568 else:
569 self.save()
570 return True, f"Pushed {self.player_location} onto directory stack"
571
572 def pop_directory(self):
573 """Pop directory from stack and change to it"""
574 if not self.directory_stack:
575 return False, "Directory stack is empty"
576
577 # Get directory from stack
578 target_dir = self.directory_stack.pop()
579
580 # Save current location as previous (for cd -)
581 self.previous_location = self.player_location
582 self.player_location = target_dir
583 self.total_directories_visited += 1
584 self.save()
585
586 return True, f"Popped and moved to {self.player_location}"
587
588 def get_directory_stack(self):
589 """Get the current directory stack for display"""
590 if not self.directory_stack:
591 return []
592 return list(self.directory_stack) + [self.player_location]
593
594 def check_win_condition(self):
595 """Check if player is in the same directory as the mole"""
596 return self.player_location == self.mole_location
597
598
599 class DirectoryNode(models.Model):
600 """A directory in the filesystem tree"""
601 tree = models.ForeignKey(FileSystemTree, on_delete=models.CASCADE, related_name='nodes')
602 parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='children')
603
604 name = models.CharField(max_length=100)
605 path = models.CharField(max_length=500, db_index=True)
606 is_fhs_standard = models.BooleanField(default=False)
607 description = models.TextField(blank=True)
608
609 class Meta:
610 unique_together = ('tree', 'path')
611 ordering = ['path']
612
613 def __str__(self):
614 return f"{self.path} ({'FHS' if self.is_fhs_standard else 'Generated'})"
615
616 @property
617 def depth(self):
618 """Calculate directory depth"""
619 return self.path.count('/')
620
621 def get_contents(self):
622 """Get immediate children of this directory"""
623 return DirectoryNode.objects.filter(parent=self).order_by('name')
624
625
626 class GameSession(models.Model):
627 """Track game sessions and scores"""
628 tree = models.ForeignKey(FileSystemTree, on_delete=models.CASCADE, related_name='sessions')
629 player_name = models.CharField(max_length=100, default="Anonymous")
630 started_at = models.DateTimeField(auto_now_add=True)
631 completed_at = models.DateTimeField(null=True, blank=True)
632
633 # Game metrics
634 commands_used = models.IntegerField(default=0)
635 directories_visited = models.IntegerField(default=0)
636 time_taken = models.DurationField(null=True, blank=True)
637 moles_killed = models.IntegerField(default=0)
638
639 # Timer stats
640 moles_escaped = models.IntegerField(default=0)
641 fastest_kill_time = models.FloatField(null=True, blank=True) # seconds
642 average_kill_time = models.FloatField(null=True, blank=True) # seconds
643
644 # Per-mole tracking
645 mole_stats = models.JSONField(default=list) # List of {mole_number, location, commands, time, distance}
646
647 # Command history
648 command_history = models.JSONField(default=list)
649
650 def __str__(self):
651 return f"{self.player_name} - {self.tree.name}"
652
653 def add_command(self, command):
654 """Add a command to the history"""
655 self.command_history.append({
656 'command': command,
657 'timestamp': str(timezone.now())
658 })
659 self.commands_used += 1
660 self.save()
661
662 def record_mole_kill(self, mole_location, commands_for_mole, time_for_mole, distance_traveled):
663 """Updated to track timer performance"""
664 # Calculate actual time to kill (not total session time)
665 if self.tree.mole_spawned_at:
666 actual_kill_time = (timezone.now() - self.tree.mole_spawned_at).total_seconds()
667
668 # Update fastest time
669 if self.fastest_kill_time is None or actual_kill_time < self.fastest_kill_time:
670 self.fastest_kill_time = actual_kill_time
671
672 # Update average (simple running average)
673 if self.average_kill_time is None:
674 self.average_kill_time = actual_kill_time
675 else:
676 total_kills = self.moles_killed + 1
677 self.average_kill_time = (
678 (self.average_kill_time * self.moles_killed + actual_kill_time)
679 / total_kills
680 )
681
682 self.moles_killed += 1
683 self.mole_stats.append({
684 'mole_number': self.moles_killed,
685 'location': mole_location,
686 'commands': commands_for_mole,
687 'time': str(time_for_mole),
688 'distance': distance_traveled
689 })
690 self.save()
691
692 def calculate_score(self):
693 """Updated scoring with timer bonuses"""
694 if self.moles_killed == 0:
695 return 0
696
697 base_score = 1000
698 score = self.moles_killed * base_score
699
700 # Calculate average performance
701 total_commands = sum(stat['commands'] for stat in self.mole_stats)
702 avg_commands = total_commands / self.moles_killed if self.moles_killed > 0 else 0
703
704 # Efficiency bonus (fewer commands is better)
705 if avg_commands > 0:
706 efficiency_multiplier = max(0.5, min(2.0, 10 / avg_commands))
707 score *= efficiency_multiplier
708
709 # Timer bonus - faster kills = more points
710 if self.average_kill_time and self.tree.default_mole_timer:
711 # Bonus for beating the timer by a good margin
712 time_ratio = self.average_kill_time / self.tree.default_mole_timer
713 if time_ratio < 0.5: # Killed in less than half the time
714 score *= 1.5
715 elif time_ratio < 0.75: # Killed in less than 3/4 time
716 score *= 1.25
717
718 # Penalty for escapes
719 if self.moles_escaped > 0:
720 escape_penalty = 0.9 ** self.moles_escaped # 10% penalty per escape
721 score *= escape_penalty
722
723 return int(score)