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