endless mode
- SHA
da833f28e14d40ff4133b4a4cae620292f8d77b8- Parents
-
bff1dc9 - Tree
9434518
da833f2
da833f28e14d40ff4133b4a4cae620292f8d77b8bff1dc9
9434518backend/apps/trees/migrations/0003_filesystemtree_active_moles_and_more.pyadded@@ -0,0 +1,63 @@ | ||
| 1 | +# Generated by Django 5.2.3 on 2025-06-15 05:16 | |
| 2 | + | |
| 3 | +from django.db import migrations, models | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + | |
| 8 | + dependencies = [ | |
| 9 | + ("trees", "0002_filesystemtree_directory_stack_and_more"), | |
| 10 | + ] | |
| 11 | + | |
| 12 | + operations = [ | |
| 13 | + migrations.AddField( | |
| 14 | + model_name="filesystemtree", | |
| 15 | + name="active_moles", | |
| 16 | + field=models.JSONField(default=list), | |
| 17 | + ), | |
| 18 | + migrations.AddField( | |
| 19 | + model_name="filesystemtree", | |
| 20 | + name="difficulty_level", | |
| 21 | + field=models.IntegerField(default=1), | |
| 22 | + ), | |
| 23 | + migrations.AddField( | |
| 24 | + model_name="filesystemtree", | |
| 25 | + name="game_mode", | |
| 26 | + field=models.CharField(default="single", max_length=20), | |
| 27 | + ), | |
| 28 | + migrations.AddField( | |
| 29 | + model_name="filesystemtree", | |
| 30 | + name="max_moles_percentage", | |
| 31 | + field=models.FloatField(default=0.15), | |
| 32 | + ), | |
| 33 | + migrations.AddField( | |
| 34 | + model_name="filesystemtree", | |
| 35 | + name="mole_spawn_interval", | |
| 36 | + field=models.FloatField(default=30.0), | |
| 37 | + ), | |
| 38 | + migrations.AddField( | |
| 39 | + model_name="filesystemtree", | |
| 40 | + name="mole_type_weights", | |
| 41 | + field=models.JSONField(default=dict), | |
| 42 | + ), | |
| 43 | + migrations.AddField( | |
| 44 | + model_name="filesystemtree", | |
| 45 | + name="next_mole_spawn", | |
| 46 | + field=models.DateTimeField(blank=True, null=True), | |
| 47 | + ), | |
| 48 | + migrations.AddField( | |
| 49 | + model_name="gamesession", | |
| 50 | + name="moles_caught", | |
| 51 | + field=models.IntegerField(default=0), | |
| 52 | + ), | |
| 53 | + migrations.AddField( | |
| 54 | + model_name="gamesession", | |
| 55 | + name="moles_escaped", | |
| 56 | + field=models.IntegerField(default=0), | |
| 57 | + ), | |
| 58 | + migrations.AddField( | |
| 59 | + model_name="gamesession", | |
| 60 | + name="score", | |
| 61 | + field=models.IntegerField(default=0), | |
| 62 | + ), | |
| 63 | + ] | |
backend/apps/trees/migrations/0004_remove_filesystemtree_active_moles_and_more.pyadded@@ -0,0 +1,53 @@ | ||
| 1 | +# Generated by Django 5.2.3 on 2025-06-15 05:51 | |
| 2 | + | |
| 3 | +from django.db import migrations | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + | |
| 8 | + dependencies = [ | |
| 9 | + ("trees", "0003_filesystemtree_active_moles_and_more"), | |
| 10 | + ] | |
| 11 | + | |
| 12 | + operations = [ | |
| 13 | + migrations.RemoveField( | |
| 14 | + model_name="filesystemtree", | |
| 15 | + name="active_moles", | |
| 16 | + ), | |
| 17 | + migrations.RemoveField( | |
| 18 | + model_name="filesystemtree", | |
| 19 | + name="difficulty_level", | |
| 20 | + ), | |
| 21 | + migrations.RemoveField( | |
| 22 | + model_name="filesystemtree", | |
| 23 | + name="game_mode", | |
| 24 | + ), | |
| 25 | + migrations.RemoveField( | |
| 26 | + model_name="filesystemtree", | |
| 27 | + name="max_moles_percentage", | |
| 28 | + ), | |
| 29 | + migrations.RemoveField( | |
| 30 | + model_name="filesystemtree", | |
| 31 | + name="mole_spawn_interval", | |
| 32 | + ), | |
| 33 | + migrations.RemoveField( | |
| 34 | + model_name="filesystemtree", | |
| 35 | + name="mole_type_weights", | |
| 36 | + ), | |
| 37 | + migrations.RemoveField( | |
| 38 | + model_name="filesystemtree", | |
| 39 | + name="next_mole_spawn", | |
| 40 | + ), | |
| 41 | + migrations.RemoveField( | |
| 42 | + model_name="gamesession", | |
| 43 | + name="moles_caught", | |
| 44 | + ), | |
| 45 | + migrations.RemoveField( | |
| 46 | + model_name="gamesession", | |
| 47 | + name="moles_escaped", | |
| 48 | + ), | |
| 49 | + migrations.RemoveField( | |
| 50 | + model_name="gamesession", | |
| 51 | + name="score", | |
| 52 | + ), | |
| 53 | + ] | |
backend/apps/trees/migrations/0005_filesystemtree_moles_killed_and_more.pyadded@@ -0,0 +1,38 @@ | ||
| 1 | +# Generated by Django 5.2.3 on 2025-06-15 18:15 | |
| 2 | + | |
| 3 | +from django.db import migrations, models | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + | |
| 8 | + dependencies = [ | |
| 9 | + ("trees", "0004_remove_filesystemtree_active_moles_and_more"), | |
| 10 | + ] | |
| 11 | + | |
| 12 | + operations = [ | |
| 13 | + migrations.AddField( | |
| 14 | + model_name="filesystemtree", | |
| 15 | + name="moles_killed", | |
| 16 | + field=models.IntegerField(default=0), | |
| 17 | + ), | |
| 18 | + migrations.AddField( | |
| 19 | + model_name="filesystemtree", | |
| 20 | + name="total_commands", | |
| 21 | + field=models.IntegerField(default=0), | |
| 22 | + ), | |
| 23 | + migrations.AddField( | |
| 24 | + model_name="filesystemtree", | |
| 25 | + name="total_directories_visited", | |
| 26 | + field=models.IntegerField(default=0), | |
| 27 | + ), | |
| 28 | + migrations.AddField( | |
| 29 | + model_name="gamesession", | |
| 30 | + name="mole_stats", | |
| 31 | + field=models.JSONField(default=list), | |
| 32 | + ), | |
| 33 | + migrations.AddField( | |
| 34 | + model_name="gamesession", | |
| 35 | + name="moles_killed", | |
| 36 | + field=models.IntegerField(default=0), | |
| 37 | + ), | |
| 38 | + ] | |
backend/apps/trees/models.pymodified@@ -4,6 +4,7 @@ from django.utils import timezone | ||
| 4 | 4 | import json |
| 5 | 5 | import random |
| 6 | 6 | import string |
| 7 | +import math | |
| 7 | 8 | |
| 8 | 9 | class FileSystemTree(models.Model): |
| 9 | 10 | """A complete filesystem tree for a game session""" |
@@ -22,6 +23,11 @@ class FileSystemTree(models.Model): | ||
| 22 | 23 | directory_stack = models.JSONField(default=list) # For pushd/popd |
| 23 | 24 | home_directory = models.CharField(max_length=500, default="/home") # Player's home |
| 24 | 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 | + | |
| 25 | 31 | # Cached tree structure |
| 26 | 32 | tree_data = models.JSONField(null=True, blank=True) |
| 27 | 33 | |
@@ -202,6 +208,112 @@ class FileSystemTree(models.Model): | ||
| 202 | 208 | mole_dir = random.choice(candidates) |
| 203 | 209 | self.mole_location = mole_dir.path |
| 204 | 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 | + | |
| 205 | 317 | def _set_random_start_position(self): |
| 206 | 318 | """Set a random starting position for the player""" |
| 207 | 319 | # Get all directories that could be valid starting positions |
@@ -329,6 +441,7 @@ class FileSystemTree(models.Model): | ||
| 329 | 441 | # Save previous location before moving |
| 330 | 442 | self.previous_location = self.player_location |
| 331 | 443 | self.player_location = new_path |
| 444 | + self.total_directories_visited += 1 | |
| 332 | 445 | self.save() |
| 333 | 446 | return True, f"Moved to {self.player_location}" |
| 334 | 447 | else: |
@@ -367,6 +480,7 @@ class FileSystemTree(models.Model): | ||
| 367 | 480 | # Save current location as previous (for cd -) |
| 368 | 481 | self.previous_location = self.player_location |
| 369 | 482 | self.player_location = target_dir |
| 483 | + self.total_directories_visited += 1 | |
| 370 | 484 | self.save() |
| 371 | 485 | |
| 372 | 486 | return True, f"Popped and moved to {self.player_location}" |
@@ -420,6 +534,10 @@ class GameSession(models.Model): | ||
| 420 | 534 | commands_used = models.IntegerField(default=0) |
| 421 | 535 | directories_visited = models.IntegerField(default=0) |
| 422 | 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} | |
| 423 | 541 | |
| 424 | 542 | # Command history |
| 425 | 543 | command_history = models.JSONField(default=list) |
@@ -434,4 +552,41 @@ class GameSession(models.Model): | ||
| 434 | 552 | 'timestamp': str(timezone.now()) |
| 435 | 553 | }) |
| 436 | 554 | self.commands_used += 1 |
| 437 | - self.save() | |
| 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) | |
backend/apps/trees/serializers.pymodified@@ -18,9 +18,10 @@ class FileSystemTreeSerializer(serializers.ModelSerializer): | ||
| 18 | 18 | fields = [ |
| 19 | 19 | 'id', 'name', 'created_at', 'seed', |
| 20 | 20 | 'player_location', 'is_completed', 'completed_at', |
| 21 | - 'tree_data', 'total_directories' | |
| 21 | + 'tree_data', 'total_directories', 'moles_killed', | |
| 22 | + 'total_commands', 'total_directories_visited' | |
| 22 | 23 | ] |
| 23 | - read_only_fields = ['created_at', 'tree_data', 'total_directories'] | |
| 24 | + read_only_fields = ['created_at', 'tree_data', 'total_directories', 'moles_killed'] | |
| 24 | 25 | |
| 25 | 26 | def get_total_directories(self, obj): |
| 26 | 27 | return obj.nodes.count() |
@@ -28,17 +29,23 @@ class FileSystemTreeSerializer(serializers.ModelSerializer): | ||
| 28 | 29 | |
| 29 | 30 | class GameSessionSerializer(serializers.ModelSerializer): |
| 30 | 31 | tree_name = serializers.CharField(source='tree.name', read_only=True) |
| 32 | + score = serializers.SerializerMethodField() | |
| 31 | 33 | |
| 32 | 34 | class Meta: |
| 33 | 35 | model = GameSession |
| 34 | 36 | fields = [ |
| 35 | 37 | 'id', 'tree', 'tree_name', 'player_name', |
| 36 | 38 | 'started_at', 'completed_at', 'commands_used', |
| 37 | - 'directories_visited', 'time_taken', 'command_history' | |
| 39 | + 'directories_visited', 'time_taken', 'command_history', | |
| 40 | + 'moles_killed', 'mole_stats', 'score' | |
| 38 | 41 | ] |
| 39 | 42 | read_only_fields = [ |
| 40 | - 'started_at', 'completed_at', 'time_taken', 'command_history' | |
| 43 | + 'started_at', 'completed_at', 'time_taken', 'command_history', | |
| 44 | + 'mole_stats', 'score' | |
| 41 | 45 | ] |
| 46 | + | |
| 47 | + def get_score(self, obj): | |
| 48 | + return obj.calculate_score() | |
| 42 | 49 | |
| 43 | 50 | |
| 44 | 51 | class GameCommandSerializer(serializers.Serializer): |
backend/apps/trees/views.pymodified@@ -94,6 +94,11 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 94 | 94 | "command": "killall moles", |
| 95 | 95 | "description": "Eliminate moles when in the same directory", |
| 96 | 96 | "examples": ["killall moles"] |
| 97 | + }, | |
| 98 | + { | |
| 99 | + "command": "score", | |
| 100 | + "description": "Show current score and moles killed", | |
| 101 | + "examples": ["score"] | |
| 97 | 102 | } |
| 98 | 103 | ], |
| 99 | 104 | "special_paths": [ |
@@ -203,12 +208,19 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 203 | 208 | status=status.HTTP_400_BAD_REQUEST |
| 204 | 209 | ) |
| 205 | 210 | |
| 211 | + # Track commands for tree | |
| 212 | + tree.total_commands += 1 | |
| 213 | + tree.save() | |
| 214 | + | |
| 206 | 215 | # Get session if provided |
| 207 | 216 | session = None |
| 217 | + commands_before_mole = tree.total_commands # Track for scoring | |
| 218 | + | |
| 208 | 219 | if session_id: |
| 209 | 220 | try: |
| 210 | 221 | session = GameSession.objects.get(id=session_id, tree=tree) |
| 211 | 222 | session.add_command(command) |
| 223 | + commands_before_mole = session.commands_used | |
| 212 | 224 | except GameSession.DoesNotExist: |
| 213 | 225 | pass |
| 214 | 226 | |
@@ -220,7 +232,10 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 220 | 232 | 'command': command, |
| 221 | 233 | 'success': False, |
| 222 | 234 | 'output': '', |
| 223 | - 'current_path': tree.player_location | |
| 235 | + 'current_path': tree.player_location, | |
| 236 | + 'mole_spawned': False, | |
| 237 | + 'mole_direction': None, | |
| 238 | + 'score': 0 | |
| 224 | 239 | } |
| 225 | 240 | |
| 226 | 241 | if cmd == 'cd': |
@@ -425,22 +440,58 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 425 | 440 | |
| 426 | 441 | elif cmd == 'killall' and len(parts) > 1 and parts[1] == 'moles': |
| 427 | 442 | if tree.check_win_condition(): |
| 428 | - tree.is_completed = True | |
| 429 | - tree.completed_at = timezone.now() | |
| 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 | |
| 430 | 448 | tree.save() |
| 431 | 449 | |
| 450 | + # Record stats for session | |
| 432 | 451 | if session: |
| 433 | - session.completed_at = timezone.now() | |
| 434 | - session.time_taken = session.completed_at - session.started_at | |
| 435 | - session.save() | |
| 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() | |
| 436 | 468 | |
| 437 | - response_data['output'] = "🎉 Congratulations! You found and eliminated the mole!" | |
| 438 | - response_data['success'] = True | |
| 439 | - response_data['game_won'] = True | |
| 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 | |
| 440 | 482 | else: |
| 441 | 483 | response_data['output'] = "No moles found in this directory." |
| 442 | 484 | response_data['success'] = True |
| 443 | 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 | + | |
| 444 | 495 | elif cmd == 'help': |
| 445 | 496 | response_data['output'] = """Available commands: |
| 446 | 497 | cd <directory> - Change directory (supports ~, -, and ..) |
@@ -453,6 +504,7 @@ pwd - Print working directory | ||
| 453 | 504 | echo <text> - Display text (supports $HOME, $PWD, $OLDPWD) |
| 454 | 505 | tree [-L depth] - Display directory tree (use -L to limit depth) |
| 455 | 506 | killall moles - Eliminate moles (when in the same directory) |
| 507 | +score - Show current score and moles killed | |
| 456 | 508 | help - Show this help message |
| 457 | 509 | |
| 458 | 510 | Special paths: |
frontend/src/components/Game.tsxmodified@@ -1,9 +1,8 @@ | ||
| 1 | -'use client'; | |
| 1 | +"use client"; | |
| 2 | 2 | |
| 3 | -// src/components/Game.tsx | |
| 4 | 3 | import React, { useState, useEffect, useRef } from 'react'; |
| 5 | 4 | import TreeVisualizer from './TreeVisualizer'; |
| 6 | -import { gameApi, FileSystemTree, FHSDirectory, CommandReferenceResponse } from '@/lib/api'; | |
| 5 | +import { gameApi, FileSystemTree, FHSDirectory, CommandReferenceResponse, MoleDirection } from '@/lib/api'; | |
| 7 | 6 | |
| 8 | 7 | interface CommandHistoryEntry { |
| 9 | 8 | command: string; |
@@ -37,6 +36,9 @@ const Game: React.FC = () => { | ||
| 37 | 36 | const [hasPlayedIntro, setHasPlayedIntro] = useState(false); |
| 38 | 37 | const [isDarkMode, setIsDarkMode] = useState(true); |
| 39 | 38 | const [moleKilled, setMoleKilled] = useState(false); |
| 39 | + const [moleDirection, setMoleDirection] = useState<MoleDirection | null>(null); | |
| 40 | + const [score, setScore] = useState(0); | |
| 41 | + const [molesKilled, setMolesKilled] = useState(0); | |
| 40 | 42 | |
| 41 | 43 | const terminalRef = useRef<HTMLDivElement>(null); |
| 42 | 44 | const inputRef = useRef<HTMLInputElement>(null); |
@@ -80,6 +82,11 @@ const Game: React.FC = () => { | ||
| 80 | 82 | error: null, |
| 81 | 83 | }); |
| 82 | 84 | |
| 85 | + // Reset game state | |
| 86 | + setScore(0); | |
| 87 | + setMolesKilled(0); | |
| 88 | + setMoleDirection(null); | |
| 89 | + | |
| 83 | 90 | // Create a more dynamic starting message based on random location |
| 84 | 91 | const startLocation = response.tree.player_location; |
| 85 | 92 | const homeDir = response.home_directory || '/home'; |
@@ -183,9 +190,57 @@ const Game: React.FC = () => { | ||
| 183 | 190 | })); |
| 184 | 191 | } |
| 185 | 192 | |
| 186 | - // Check if game won | |
| 187 | - if (response.game_won) { | |
| 188 | - // First, show the mole | |
| 193 | + // Check if a new mole was spawned | |
| 194 | + if (response.mole_spawned) { | |
| 195 | + // First, show the killed mole briefly | |
| 196 | + setGameState(prev => ({ | |
| 197 | + ...prev, | |
| 198 | + tree: prev.tree ? { | |
| 199 | + ...prev.tree, | |
| 200 | + tree_data: updateTreeDataToShowMole(prev.tree!.tree_data, prev.tree!.player_location), | |
| 201 | + } : null, | |
| 202 | + })); | |
| 203 | + | |
| 204 | + // Trigger falling animation | |
| 205 | + setMoleKilled(true); | |
| 206 | + | |
| 207 | + // After animation, update tree with new mole location | |
| 208 | + setTimeout(() => { | |
| 209 | + setMoleKilled(false); | |
| 210 | + | |
| 211 | + // Update tree to show new mole location | |
| 212 | + if (response.new_mole_location && gameState.tree) { | |
| 213 | + const treeWithNewMole = updateTreeDataToShowMole( | |
| 214 | + removeMoleFromTree(gameState.tree.tree_data), | |
| 215 | + response.new_mole_location | |
| 216 | + ); | |
| 217 | + | |
| 218 | + setGameState(prev => ({ | |
| 219 | + ...prev, | |
| 220 | + tree: prev.tree ? { | |
| 221 | + ...prev.tree, | |
| 222 | + tree_data: treeWithNewMole, | |
| 223 | + } : null, | |
| 224 | + })); | |
| 225 | + } | |
| 226 | + | |
| 227 | + // Set new mole direction | |
| 228 | + if (response.mole_direction) { | |
| 229 | + setMoleDirection(response.mole_direction); | |
| 230 | + // Hide direction indicator after 5 seconds | |
| 231 | + setTimeout(() => { | |
| 232 | + setMoleDirection(null); | |
| 233 | + }, 5000); | |
| 234 | + } | |
| 235 | + }, 1500); // Wait for falling animation | |
| 236 | + | |
| 237 | + // Update score and moles killed | |
| 238 | + if (response.score !== undefined) setScore(response.score); | |
| 239 | + if (response.moles_killed !== undefined) setMolesKilled(response.moles_killed); | |
| 240 | + } | |
| 241 | + | |
| 242 | + // Legacy: Check if game won (for old backend compatibility) | |
| 243 | + if (response.game_won && !response.mole_spawned) { | |
| 189 | 244 | setGameState(prev => ({ |
| 190 | 245 | ...prev, |
| 191 | 246 | tree: prev.tree ? { |
@@ -195,7 +250,6 @@ const Game: React.FC = () => { | ||
| 195 | 250 | } : null, |
| 196 | 251 | })); |
| 197 | 252 | |
| 198 | - // Then trigger the falling animation after a short delay | |
| 199 | 253 | setTimeout(() => { |
| 200 | 254 | setMoleKilled(true); |
| 201 | 255 | }, 200); |
@@ -210,11 +264,10 @@ const Game: React.FC = () => { | ||
| 210 | 264 | }]); |
| 211 | 265 | } finally { |
| 212 | 266 | setExecuting(false); |
| 213 | - // Focus will be restored by useEffect | |
| 214 | 267 | } |
| 215 | 268 | }; |
| 216 | 269 | |
| 217 | - // Update tree data to show mole when game is won | |
| 270 | + // Update tree data to show mole when found | |
| 218 | 271 | const updateTreeDataToShowMole = (treeData: any, molePath: string): any => { |
| 219 | 272 | if (treeData.path === molePath) { |
| 220 | 273 | return { ...treeData, has_mole: true }; |
@@ -230,6 +283,15 @@ const Game: React.FC = () => { | ||
| 230 | 283 | return treeData; |
| 231 | 284 | }; |
| 232 | 285 | |
| 286 | + // Remove mole from tree data | |
| 287 | + const removeMoleFromTree = (treeData: any): any => { | |
| 288 | + return { | |
| 289 | + ...treeData, | |
| 290 | + has_mole: false, | |
| 291 | + children: treeData.children ? treeData.children.map((child: any) => removeMoleFromTree(child)) : [] | |
| 292 | + }; | |
| 293 | + }; | |
| 294 | + | |
| 233 | 295 | // Handle node click in visualizer |
| 234 | 296 | const handleNodeClick = (path: string) => { |
| 235 | 297 | executeCommand(`cd ${path}`); |
@@ -243,14 +305,33 @@ const Game: React.FC = () => { | ||
| 243 | 305 | // Mark intro as played after first render |
| 244 | 306 | useEffect(() => { |
| 245 | 307 | if (gameState.tree && !hasPlayedIntro) { |
| 246 | - // Set timeout to mark intro as played after animation completes | |
| 247 | 308 | const timer = setTimeout(() => { |
| 248 | 309 | setHasPlayedIntro(true); |
| 249 | - }, 6500); // Total intro duration | |
| 310 | + }, 10000); // Add a buffer to ensure animation completes (9.3s + buffer) | |
| 250 | 311 | return () => clearTimeout(timer); |
| 251 | 312 | } |
| 252 | 313 | }, [gameState.tree, hasPlayedIntro]); |
| 253 | 314 | |
| 315 | + // Get position for mole direction indicator | |
| 316 | + const getMoleIndicatorPosition = (direction: string) => { | |
| 317 | + const positions: Record<string, string> = { | |
| 318 | + 'up': 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-full -mt-8', | |
| 319 | + 'down': 'bottom-20 left-1/2 -translate-x-1/2', | |
| 320 | + 'left': 'top-1/2 left-8 -translate-y-1/2', | |
| 321 | + 'right': 'top-1/2 right-8 -translate-y-1/2', | |
| 322 | + 'up-left': 'top-20 left-8', | |
| 323 | + 'up-right': 'top-20 right-8', | |
| 324 | + 'down-left': 'bottom-20 left-8', | |
| 325 | + 'down-right': 'bottom-20 right-8', | |
| 326 | + }; | |
| 327 | + return positions[direction] || positions['up']; | |
| 328 | + }; | |
| 329 | + | |
| 330 | + // Get rotation for arrow based on angle | |
| 331 | + const getArrowRotation = (angle: number) => { | |
| 332 | + return `rotate(${angle}deg)`; | |
| 333 | + }; | |
| 334 | + | |
| 254 | 335 | // Terminal color scheme based on dark/light mode |
| 255 | 336 | const terminalColors = isDarkMode ? { |
| 256 | 337 | frame: 'bg-stone-200 border-stone-300', |
@@ -327,6 +408,40 @@ const Game: React.FC = () => { | ||
| 327 | 408 | /> |
| 328 | 409 | </div> |
| 329 | 410 | |
| 411 | + {/* Mole Direction Indicator */} | |
| 412 | + {moleDirection && ( | |
| 413 | + <div | |
| 414 | + className={`absolute ${getMoleIndicatorPosition(moleDirection.direction)} z-40 animate-pulse`} | |
| 415 | + style={{ | |
| 416 | + animation: 'pulse 2s ease-in-out infinite, fadeIn 0.5s ease-out' | |
| 417 | + }} | |
| 418 | + > | |
| 419 | + <div className="bg-red-600/90 backdrop-blur-sm border-2 border-red-400 rounded-lg p-3 shadow-2xl flex items-center gap-2"> | |
| 420 | + <img | |
| 421 | + src="/mole.svg" | |
| 422 | + alt="Mole" | |
| 423 | + className="w-8 h-8" | |
| 424 | + /> | |
| 425 | + <div | |
| 426 | + className="text-white text-2xl" | |
| 427 | + style={{ transform: getArrowRotation(moleDirection.angle) }} | |
| 428 | + > | |
| 429 | + → | |
| 430 | + </div> | |
| 431 | + </div> | |
| 432 | + </div> | |
| 433 | + )} | |
| 434 | + | |
| 435 | + {/* Score Display - Top Right */} | |
| 436 | + {molesKilled > 0 && ( | |
| 437 | + <div className="absolute top-4 right-4 bg-black/80 backdrop-blur-sm border border-green-500 rounded-lg p-3 shadow-2xl z-30"> | |
| 438 | + <div className="text-green-400 font-terminal text-sm"> | |
| 439 | + <div>Score: {score}</div> | |
| 440 | + <div>Moles: {molesKilled}</div> | |
| 441 | + </div> | |
| 442 | + </div> | |
| 443 | + )} | |
| 444 | + | |
| 330 | 445 | {/* Floating Terminal - Top Left */} |
| 331 | 446 | <div className={`absolute top-4 left-4 ${terminalColors.frame} rounded-lg shadow-2xl border transition-all duration-300 z-30 ${ |
| 332 | 447 | terminalMinimized ? 'w-80' : 'w-[700px]' |
@@ -342,17 +457,13 @@ const Game: React.FC = () => { | ||
| 342 | 457 | > |
| 343 | 458 | <span className="text-[8px] font-bold text-gray-900 absolute">×</span> |
| 344 | 459 | </button> |
| 345 | - {gameState.tree && !gameState.tree.is_completed ? ( | |
| 346 | - <button | |
| 347 | - onClick={getHints} | |
| 348 | - className="w-3.5 h-3.5 bg-yellow-500 hover:bg-yellow-400 rounded-full flex items-center justify-center transition-colors relative" | |
| 349 | - title="Get Hint" | |
| 350 | - > | |
| 351 | - <span className="text-[9px] font-bold text-gray-900 absolute">?</span> | |
| 352 | - </button> | |
| 353 | - ) : ( | |
| 354 | - <div className="w-3.5 h-3.5 bg-yellow-500 rounded-full"></div> | |
| 355 | - )} | |
| 460 | + <button | |
| 461 | + onClick={getHints} | |
| 462 | + className="w-3.5 h-3.5 bg-yellow-500 hover:bg-yellow-400 rounded-full flex items-center justify-center transition-colors relative" | |
| 463 | + title="Get Hint" | |
| 464 | + > | |
| 465 | + <span className="text-[9px] font-bold text-gray-900 absolute">?</span> | |
| 466 | + </button> | |
| 356 | 467 | <button |
| 357 | 468 | onClick={getFHSReference} |
| 358 | 469 | className="w-3.5 h-3.5 bg-green-500 hover:bg-green-400 rounded-full flex items-center justify-center transition-colors relative" |
@@ -427,7 +538,7 @@ const Game: React.FC = () => { | ||
| 427 | 538 | executeCommand(command); |
| 428 | 539 | } |
| 429 | 540 | }} |
| 430 | - disabled={executing || gameState.tree?.is_completed} | |
| 541 | + disabled={executing} | |
| 431 | 542 | className="absolute inset-0 w-full bg-transparent text-transparent outline-none caret-transparent font-terminal" |
| 432 | 543 | placeholder="" |
| 433 | 544 | autoFocus |
@@ -443,7 +554,7 @@ const Game: React.FC = () => { | ||
| 443 | 554 | )} |
| 444 | 555 | </div> |
| 445 | 556 | |
| 446 | - {/* Hints Popup - Now positioned relative to terminal */} | |
| 557 | + {/* Hints Popup */} | |
| 447 | 558 | {showHints && hints.length > 0 && ( |
| 448 | 559 | <div className="absolute top-16 left-4 bg-yellow-900/95 backdrop-blur-sm border border-yellow-600 rounded-lg p-4 max-w-md z-40 shadow-2xl"> |
| 449 | 560 | <button |
@@ -600,7 +711,7 @@ const Game: React.FC = () => { | ||
| 600 | 711 | </div> |
| 601 | 712 | )} |
| 602 | 713 | |
| 603 | - {/* Bottom Game Bar - Updated with blue shade */} | |
| 714 | + {/* Bottom Game Bar */} | |
| 604 | 715 | <div className={`absolute bottom-0 left-0 right-0 ${isDarkMode ? 'bg-slate-800/90' : 'bg-blue-50/90'} backdrop-blur-sm border-t ${isDarkMode ? 'border-slate-700' : 'border-blue-200'} p-3 z-20`}> |
| 605 | 716 | <div className="max-w-7xl mx-auto flex justify-between items-center px-4"> |
| 606 | 717 | <div className="flex items-center gap-4"> |
@@ -608,15 +719,9 @@ const Game: React.FC = () => { | ||
| 608 | 719 | </div> |
| 609 | 720 | |
| 610 | 721 | <div className="flex items-center gap-3"> |
| 611 | - {gameState.tree.is_completed ? ( | |
| 612 | - <div className="text-green-600 dark:text-green-400 font-bold animate-pulse"> | |
| 613 | - You found a mole! | |
| 614 | - </div> | |
| 615 | - ) : ( | |
| 616 | - <div className={`text-xs ${isDarkMode ? 'text-slate-400' : 'text-blue-700'}`}> | |
| 617 | - click adjacent nodes or use the terminal | |
| 618 | - </div> | |
| 619 | - )} | |
| 722 | + <div className={`text-xs ${isDarkMode ? 'text-slate-400' : 'text-blue-700'}`}> | |
| 723 | + click adjacent nodes or use the terminal | |
| 724 | + </div> | |
| 620 | 725 | <button |
| 621 | 726 | onClick={startNewGame} |
| 622 | 727 | className={`px-3 py-1.5 ${isDarkMode ? 'bg-slate-700 hover:bg-slate-600' : 'bg-blue-200 hover:bg-blue-300'} ${isDarkMode ? 'text-white' : 'text-blue-900'} text-sm rounded transition`} |
@@ -626,6 +731,18 @@ const Game: React.FC = () => { | ||
| 626 | 731 | </div> |
| 627 | 732 | </div> |
| 628 | 733 | </div> |
| 734 | + | |
| 735 | + {/* Custom styles for animations */} | |
| 736 | + <style jsx>{` | |
| 737 | + @keyframes fadeIn { | |
| 738 | + from { opacity: 0; transform: scale(0.8); } | |
| 739 | + to { opacity: 1; transform: scale(1); } | |
| 740 | + } | |
| 741 | + @keyframes pulse { | |
| 742 | + 0%, 100% { opacity: 0.9; transform: scale(1); } | |
| 743 | + 50% { opacity: 1; transform: scale(1.05); } | |
| 744 | + } | |
| 745 | + `}</style> | |
| 629 | 746 | </div> |
| 630 | 747 | ); |
| 631 | 748 | }; |
frontend/src/lib/api.tsmodified@@ -29,6 +29,14 @@ export interface FileSystemTree { | ||
| 29 | 29 | completed_at: string | null; |
| 30 | 30 | tree_data: TreeNode; |
| 31 | 31 | total_directories: number; |
| 32 | + moles_killed?: number; | |
| 33 | + total_commands?: number; | |
| 34 | + total_directories_visited?: number; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface MoleDirection { | |
| 38 | + direction: string; | |
| 39 | + angle: number; | |
| 32 | 40 | } |
| 33 | 41 | |
| 34 | 42 | export interface CommandResponse { |
@@ -37,6 +45,11 @@ export interface CommandResponse { | ||
| 37 | 45 | output: string; |
| 38 | 46 | current_path: string; |
| 39 | 47 | game_won?: boolean; |
| 48 | + mole_spawned?: boolean; | |
| 49 | + mole_direction?: MoleDirection | null; | |
| 50 | + score?: number; | |
| 51 | + moles_killed?: number; | |
| 52 | + new_mole_location?: string; | |
| 40 | 53 | } |
| 41 | 54 | |
| 42 | 55 | export interface GameCreationResponse { |