expand command functionality
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
703cd03f47782768e981ef46f2c774c51105bf79- Parents
-
9bb5d95 - Tree
4945a6a
703cd03
703cd03f47782768e981ef46f2c774c51105bf799bb5d95
4945a6a| Status | File | + | - |
|---|---|---|---|
| A |
backend/apps/trees/migrations/0002_filesystemtree_directory_stack_and_more.py
|
28 | 0 |
| M |
backend/apps/trees/models.py
|
116 | 17 |
| M |
backend/apps/trees/views.py
|
108 | 9 |
| M |
frontend/src/components/Game.tsx
|
2 | 1 |
| M |
frontend/src/lib/api.ts
|
1 | 0 |
backend/apps/trees/migrations/0002_filesystemtree_directory_stack_and_more.pyadded@@ -0,0 +1,28 @@ | ||
| 1 | +# Generated by Django 5.2.3 on 2025-06-15 01:27 | |
| 2 | + | |
| 3 | +from django.db import migrations, models | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + | |
| 8 | + dependencies = [ | |
| 9 | + ("trees", "0001_initial"), | |
| 10 | + ] | |
| 11 | + | |
| 12 | + operations = [ | |
| 13 | + migrations.AddField( | |
| 14 | + model_name="filesystemtree", | |
| 15 | + name="directory_stack", | |
| 16 | + field=models.JSONField(default=list), | |
| 17 | + ), | |
| 18 | + migrations.AddField( | |
| 19 | + model_name="filesystemtree", | |
| 20 | + name="home_directory", | |
| 21 | + field=models.CharField(default="/home", max_length=500), | |
| 22 | + ), | |
| 23 | + migrations.AddField( | |
| 24 | + model_name="filesystemtree", | |
| 25 | + name="previous_location", | |
| 26 | + field=models.CharField(default="", max_length=500), | |
| 27 | + ), | |
| 28 | + ] | |
backend/apps/trees/models.pymodified@@ -17,6 +17,11 @@ class FileSystemTree(models.Model): | ||
| 17 | 17 | is_completed = models.BooleanField(default=False) |
| 18 | 18 | completed_at = models.DateTimeField(null=True, blank=True) |
| 19 | 19 | |
| 20 | + # Navigation state | |
| 21 | + previous_location = models.CharField(max_length=500, default="") # For cd - | |
| 22 | + directory_stack = models.JSONField(default=list) # For pushd/popd | |
| 23 | + home_directory = models.CharField(max_length=500, default="/home") # Player's home | |
| 24 | + | |
| 20 | 25 | # Cached tree structure |
| 21 | 26 | tree_data = models.JSONField(null=True, blank=True) |
| 22 | 27 | |
@@ -231,9 +236,22 @@ class FileSystemTree(models.Model): | ||
| 231 | 236 | # Select random starting position with weights |
| 232 | 237 | start_node = random.choices(valid_starts, weights=weights, k=1)[0] |
| 233 | 238 | self.player_location = start_node.path |
| 239 | + | |
| 240 | + # Set home directory based on starting location | |
| 241 | + if start_node.path.startswith('/home/'): | |
| 242 | + # Extract the user's home directory | |
| 243 | + parts = start_node.path.split('/') | |
| 244 | + if len(parts) >= 3: | |
| 245 | + self.home_directory = f"/home/{parts[2]}" | |
| 246 | + else: | |
| 247 | + self.home_directory = "/home" | |
| 248 | + else: | |
| 249 | + # Default home directory | |
| 250 | + self.home_directory = "/home" | |
| 234 | 251 | else: |
| 235 | 252 | # Fallback to /home if no valid candidates |
| 236 | 253 | self.player_location = "/home" |
| 254 | + self.home_directory = "/home" | |
| 237 | 255 | |
| 238 | 256 | def cache_tree(self): |
| 239 | 257 | """Cache the tree structure for efficient retrieval""" |
@@ -251,32 +269,113 @@ class FileSystemTree(models.Model): | ||
| 251 | 269 | root = DirectoryNode.objects.get(tree=self, path="/") |
| 252 | 270 | self.tree_data = build_tree_dict(root) |
| 253 | 271 | |
| 272 | + def resolve_path(self, path): | |
| 273 | + """Resolve a path that may contain ~ or be relative""" | |
| 274 | + if path == "~": | |
| 275 | + return self.home_directory | |
| 276 | + elif path.startswith("~/"): | |
| 277 | + return self.home_directory + path[1:] | |
| 278 | + elif path == "-": | |
| 279 | + return self.previous_location if self.previous_location else self.player_location | |
| 280 | + elif not path.startswith('/'): | |
| 281 | + # Relative path | |
| 282 | + if self.player_location == "/": | |
| 283 | + return "/" + path | |
| 284 | + else: | |
| 285 | + return self.player_location + "/" + path | |
| 286 | + else: | |
| 287 | + # Absolute path | |
| 288 | + return path | |
| 289 | + | |
| 290 | + def normalize_path(self, path): | |
| 291 | + """Normalize a path by resolving .. and . components""" | |
| 292 | + parts = path.split('/') | |
| 293 | + resolved = [] | |
| 294 | + | |
| 295 | + for part in parts: | |
| 296 | + if part == '' and len(resolved) == 0: | |
| 297 | + # Leading slash | |
| 298 | + resolved.append('') | |
| 299 | + elif part == '..': | |
| 300 | + if len(resolved) > 1: | |
| 301 | + resolved.pop() | |
| 302 | + elif part != '.' and part != '': | |
| 303 | + resolved.append(part) | |
| 304 | + | |
| 305 | + if len(resolved) == 1 and resolved[0] == '': | |
| 306 | + return '/' | |
| 307 | + return '/'.join(resolved) | |
| 308 | + | |
| 254 | 309 | def move_player(self, target_path): |
| 255 | 310 | """Move player to a new location if valid""" |
| 256 | - # Normalize path | |
| 257 | - if not target_path.startswith('/'): | |
| 258 | - # Relative path | |
| 259 | - if target_path == "..": | |
| 260 | - # Go up one directory | |
| 311 | + # Resolve special path symbols | |
| 312 | + resolved_path = self.resolve_path(target_path) | |
| 313 | + | |
| 314 | + # Handle ".." in the path | |
| 315 | + if ".." in resolved_path or resolved_path == "..": | |
| 316 | + if resolved_path == "..": | |
| 317 | + # Go up one directory from current location | |
| 261 | 318 | if self.player_location == "/": |
| 262 | 319 | return False, "Already at root directory" |
| 263 | - self.player_location = "/".join(self.player_location.split("/")[:-1]) or "/" | |
| 320 | + new_path = "/".join(self.player_location.split("/")[:-1]) or "/" | |
| 321 | + else: | |
| 322 | + # Normalize the path to handle .. components | |
| 323 | + new_path = self.normalize_path(resolved_path) | |
| 264 | 324 | else: |
| 265 | - # Go to subdirectory | |
| 266 | - new_path = f"{self.player_location}/{target_path}" if self.player_location != "/" else f"/{target_path}" | |
| 325 | + new_path = resolved_path | |
| 326 | + | |
| 327 | + # Check if the directory exists | |
| 267 | 328 | if DirectoryNode.objects.filter(tree=self, path=new_path).exists(): |
| 329 | + # Save previous location before moving | |
| 330 | + self.previous_location = self.player_location | |
| 268 | 331 | self.player_location = new_path |
| 332 | + self.save() | |
| 333 | + return True, f"Moved to {self.player_location}" | |
| 269 | 334 | else: |
| 270 | 335 | return False, f"Directory not found: {target_path}" |
| 336 | + | |
| 337 | + def push_directory(self, target_path=None): | |
| 338 | + """Push current directory onto stack and optionally change to new directory""" | |
| 339 | + # Add current directory to stack | |
| 340 | + if not isinstance(self.directory_stack, list): | |
| 341 | + self.directory_stack = [] | |
| 342 | + | |
| 343 | + self.directory_stack.append(self.player_location) | |
| 344 | + | |
| 345 | + # If target path provided, change to it | |
| 346 | + if target_path: | |
| 347 | + success, message = self.move_player(target_path) | |
| 348 | + if success: | |
| 349 | + self.save() | |
| 350 | + return True, f"Pushed {self.directory_stack[-1]} and moved to {self.player_location}" | |
| 271 | 351 | else: |
| 272 | - # Absolute path | |
| 273 | - if DirectoryNode.objects.filter(tree=self, path=target_path).exists(): | |
| 274 | - self.player_location = target_path | |
| 352 | + # Remove from stack if move failed | |
| 353 | + self.directory_stack.pop() | |
| 354 | + return False, message | |
| 275 | 355 | else: |
| 276 | - return False, f"Directory not found: {target_path}" | |
| 356 | + self.save() | |
| 357 | + return True, f"Pushed {self.player_location} onto directory stack" | |
| 358 | + | |
| 359 | + def pop_directory(self): | |
| 360 | + """Pop directory from stack and change to it""" | |
| 361 | + if not self.directory_stack: | |
| 362 | + return False, "Directory stack is empty" | |
| 363 | + | |
| 364 | + # Get directory from stack | |
| 365 | + target_dir = self.directory_stack.pop() | |
| 277 | 366 | |
| 367 | + # Save current location as previous (for cd -) | |
| 368 | + self.previous_location = self.player_location | |
| 369 | + self.player_location = target_dir | |
| 278 | 370 | self.save() |
| 279 | - return True, f"Moved to {self.player_location}" | |
| 371 | + | |
| 372 | + return True, f"Popped and moved to {self.player_location}" | |
| 373 | + | |
| 374 | + def get_directory_stack(self): | |
| 375 | + """Get the current directory stack for display""" | |
| 376 | + if not self.directory_stack: | |
| 377 | + return [] | |
| 378 | + return list(self.directory_stack) + [self.player_location] | |
| 280 | 379 | |
| 281 | 380 | def check_win_condition(self): |
| 282 | 381 | """Check if player is in the same directory as the mole""" |
backend/apps/trees/views.pymodified@@ -39,7 +39,8 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 39 | 39 | return Response({ |
| 40 | 40 | 'tree': serializer.data, |
| 41 | 41 | 'session_id': session.id, |
| 42 | - 'mole_hint': f"The mole is hiding somewhere in the filesystem!" | |
| 42 | + 'mole_hint': f"The mole is hiding somewhere in the filesystem!", | |
| 43 | + 'home_directory': tree.home_directory | |
| 43 | 44 | }, status=status.HTTP_201_CREATED) |
| 44 | 45 | |
| 45 | 46 | @action(detail=True, methods=['get']) |
@@ -100,7 +101,11 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 100 | 101 | |
| 101 | 102 | if cmd == 'cd': |
| 102 | 103 | if len(parts) < 2: |
| 103 | - response_data['output'] = "cd: missing operand" | |
| 104 | + # cd with no args goes to home directory | |
| 105 | + success, message = tree.move_player("~") | |
| 106 | + response_data['success'] = success | |
| 107 | + response_data['output'] = message if not success else "" | |
| 108 | + response_data['current_path'] = tree.player_location | |
| 104 | 109 | else: |
| 105 | 110 | target = parts[1] |
| 106 | 111 | success, message = tree.move_player(target) |
@@ -112,17 +117,84 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 112 | 117 | session.directories_visited += 1 |
| 113 | 118 | session.save() |
| 114 | 119 | |
| 120 | + elif cmd == 'pushd': | |
| 121 | + if len(parts) < 2: | |
| 122 | + # pushd with no args swaps top two directories on stack | |
| 123 | + if tree.directory_stack: | |
| 124 | + success, message = tree.push_directory() | |
| 125 | + response_data['success'] = success | |
| 126 | + response_data['output'] = message | |
| 127 | + else: | |
| 128 | + response_data['output'] = "pushd: no other directory" | |
| 129 | + else: | |
| 130 | + target = parts[1] | |
| 131 | + success, message = tree.push_directory(target) | |
| 132 | + response_data['success'] = success | |
| 133 | + response_data['output'] = message | |
| 134 | + response_data['current_path'] = tree.player_location | |
| 135 | + | |
| 136 | + if success and session: | |
| 137 | + session.directories_visited += 1 | |
| 138 | + session.save() | |
| 139 | + | |
| 140 | + elif cmd == 'popd': | |
| 141 | + success, message = tree.pop_directory() | |
| 142 | + response_data['success'] = success | |
| 143 | + response_data['output'] = message | |
| 144 | + response_data['current_path'] = tree.player_location | |
| 145 | + | |
| 146 | + if success and session: | |
| 147 | + session.directories_visited += 1 | |
| 148 | + session.save() | |
| 149 | + | |
| 150 | + elif cmd == 'dirs': | |
| 151 | + # Show directory stack | |
| 152 | + stack = tree.get_directory_stack() | |
| 153 | + if stack: | |
| 154 | + response_data['output'] = ' '.join(stack) | |
| 155 | + else: | |
| 156 | + response_data['output'] = tree.player_location | |
| 157 | + response_data['success'] = True | |
| 158 | + | |
| 115 | 159 | elif cmd == 'ls': |
| 160 | + # Handle ls with options | |
| 161 | + show_all = '-a' in parts or '-la' in parts or '-al' in parts | |
| 162 | + long_format = '-l' in parts or '-la' in parts or '-al' in parts | |
| 163 | + | |
| 116 | 164 | try: |
| 117 | 165 | current_dir = DirectoryNode.objects.get( |
| 118 | 166 | tree=tree, |
| 119 | 167 | path=tree.player_location |
| 120 | 168 | ) |
| 121 | 169 | contents = current_dir.get_contents() |
| 170 | + | |
| 171 | + output_lines = [] | |
| 172 | + | |
| 173 | + if show_all: | |
| 174 | + # Add . and .. entries | |
| 175 | + if long_format: | |
| 176 | + output_lines.append("drwxr-xr-x . " + current_dir.description) | |
| 177 | + if current_dir.parent: | |
| 178 | + output_lines.append("drwxr-xr-x .. " + current_dir.parent.description) | |
| 179 | + else: | |
| 180 | + output_lines.extend(['.', '..']) | |
| 181 | + | |
| 122 | 182 | if contents: |
| 123 | - response_data['output'] = '\n'.join([d.name for d in contents]) | |
| 183 | + for d in contents: | |
| 184 | + if long_format: | |
| 185 | + output_lines.append(f"drwxr-xr-x {d.name} {d.description}") | |
| 186 | + else: | |
| 187 | + output_lines.append(d.name) | |
| 188 | + | |
| 189 | + if long_format: | |
| 190 | + response_data['output'] = '\n'.join(output_lines) | |
| 191 | + else: | |
| 192 | + # Format in columns for regular ls | |
| 193 | + if output_lines: | |
| 194 | + response_data['output'] = ' '.join(output_lines) | |
| 124 | 195 | else: |
| 125 | 196 | response_data['output'] = '' |
| 197 | + | |
| 126 | 198 | response_data['success'] = True |
| 127 | 199 | except DirectoryNode.DoesNotExist: |
| 128 | 200 | response_data['output'] = "ls: cannot access directory" |
@@ -131,6 +203,23 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 131 | 203 | response_data['output'] = tree.player_location |
| 132 | 204 | response_data['success'] = True |
| 133 | 205 | |
| 206 | + elif cmd == 'echo': | |
| 207 | + # Simple echo implementation | |
| 208 | + if len(parts) > 1: | |
| 209 | + # Handle special variables | |
| 210 | + echo_text = ' '.join(parts[1:]) | |
| 211 | + if echo_text == '$HOME': | |
| 212 | + response_data['output'] = tree.home_directory | |
| 213 | + elif echo_text == '$PWD': | |
| 214 | + response_data['output'] = tree.player_location | |
| 215 | + elif echo_text == '$OLDPWD': | |
| 216 | + response_data['output'] = tree.previous_location or '' | |
| 217 | + else: | |
| 218 | + response_data['output'] = echo_text | |
| 219 | + else: | |
| 220 | + response_data['output'] = '' | |
| 221 | + response_data['success'] = True | |
| 222 | + | |
| 134 | 223 | elif cmd == 'killall' and len(parts) > 1 and parts[1] == 'moles': |
| 135 | 224 | if tree.check_win_condition(): |
| 136 | 225 | tree.is_completed = True |
@@ -151,11 +240,21 @@ class FileSystemTreeViewSet(viewsets.ModelViewSet): | ||
| 151 | 240 | |
| 152 | 241 | elif cmd == 'help': |
| 153 | 242 | response_data['output'] = """Available commands: |
| 154 | -cd <directory> - Change directory | |
| 155 | -ls - List directory contents | |
| 243 | +cd <directory> - Change directory (supports ~, -, and ..) | |
| 244 | +cd - Go to home directory | |
| 245 | +pushd <directory> - Push directory onto stack and change to it | |
| 246 | +popd - Pop directory from stack and change to it | |
| 247 | +dirs - Display directory stack | |
| 248 | +ls [-la] - List directory contents | |
| 156 | 249 | pwd - Print working directory |
| 250 | +echo <text> - Display text (supports $HOME, $PWD, $OLDPWD) | |
| 157 | 251 | killall moles - Eliminate moles (when in the same directory) |
| 158 | -help - Show this help message""" | |
| 252 | +help - Show this help message | |
| 253 | + | |
| 254 | +Special paths: | |
| 255 | +~ - Home directory | |
| 256 | +- - Previous directory | |
| 257 | +.. - Parent directory""" | |
| 159 | 258 | response_data['success'] = True |
| 160 | 259 | |
| 161 | 260 | else: |
frontend/src/components/Game.tsxmodified@@ -63,6 +63,7 @@ const Game: React.FC = () => { | ||
| 63 | 63 | |
| 64 | 64 | // Create a more dynamic starting message based on random location |
| 65 | 65 | const startLocation = response.tree.player_location; |
| 66 | + const homeDir = response.home_directory || '/home'; | |
| 66 | 67 | let locationContext = ''; |
| 67 | 68 | |
| 68 | 69 | if (startLocation.startsWith('/home')) { |
@@ -79,7 +80,7 @@ const Game: React.FC = () => { | ||
| 79 | 80 | |
| 80 | 81 | setCommandHistory([{ |
| 81 | 82 | command: 'Hunt started!', |
| 82 | - output: `${response.mole_hint}\n${locationContext}Use 'pwd' to see where you are.\nType "help" for available commands.`, | |
| 83 | + output: `${response.mole_hint}\n${locationContext}Your home directory is ${homeDir}.\nUse 'pwd' to see where you are, 'cd ~' to go home.\nType "help" for available commands.`, | |
| 83 | 84 | success: true, |
| 84 | 85 | }]); |
| 85 | 86 | setHints([]); |
frontend/src/lib/api.tsmodified@@ -43,6 +43,7 @@ export interface GameCreationResponse { | ||
| 43 | 43 | tree: FileSystemTree; |
| 44 | 44 | session_id: number; |
| 45 | 45 | mole_hint: string; |
| 46 | + home_directory: string; | |
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | export interface HintResponse { |