├── .gitignore ├── LEVELS.md ├── LICENSE ├── README.md ├── achievements.json ├── leaderboard.json ├── package-lock.json ├── package.json ├── saves └── placeholder ├── screenshot.png ├── src ├── core │ ├── achievements.ts │ ├── gameInit.ts │ ├── gameState.ts │ ├── gameUI.ts │ ├── leaderboard.ts │ ├── levelSystem.ts │ └── playerProfile.ts ├── index.ts ├── levels │ ├── index.ts │ ├── level1.ts │ ├── level2.ts │ ├── level3.ts │ ├── level4.ts │ └── level5.ts └── ui │ ├── commandHistory.ts │ ├── entryMenu.ts │ ├── gameUI.ts │ ├── levelRenderer.ts │ ├── mainMenu.ts │ ├── progressMap.ts │ ├── soundEffects.ts │ ├── uiHelpers.ts │ └── visualEffects.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | *.lock 5 | leaderboard.json 6 | saves 7 | profiles -------------------------------------------------------------------------------- /LEVELS.md: -------------------------------------------------------------------------------- 1 | # LEVELS.md - Guide to Adding New Levels 2 | 3 | ## Introduction 4 | 5 | ShellQuest is designed to be easily expandable with new levels. This guide explains how to create and integrate new levels into the game. 6 | 7 | ## Level Structure 8 | 9 | Each level is defined as an object that implements the `Level` interface. A level consists of: 10 | 11 | 1. **Basic Information**: ID, name, and description 12 | 2. **State Management**: Methods to initialize and manage level-specific state 13 | 3. **User Interface**: Methods to render the level and handle user input 14 | 4. **Hints**: An array of progressive hints for players who get stuck 15 | 16 | ## Creating a New Level 17 | 18 | ### Step 1: Create a new file 19 | 20 | Create a new file in the `src/levels` directory, e.g., `levelX.ts` where X is the next level number. 21 | 22 | ### Step 2: Implement the Level interface 23 | 24 | ```typescript 25 | import { Level, LevelResult, registerLevel } from '../core/levelSystem'; 26 | import { getCurrentGameState } from '../core/gameState'; 27 | import { levelUI } from '../ui/levelRenderer'; // Optional, for enhanced UI 28 | 29 | const level: Level = { 30 | id: X, // Replace X with the next level number 31 | name: 'Your Level Name', 32 | description: 'Brief description of your level', 33 | 34 | async initialize() { 35 | const gameState = getCurrentGameState(); 36 | if (!gameState) return; 37 | 38 | // Initialize level state if not already present 39 | if (!gameState.levelStates[this.id]) { 40 | gameState.levelStates[this.id] = { 41 | // Define your level-specific state here 42 | // For example: 43 | attempts: 0, 44 | someFlag: false, 45 | // Add any other state variables your level needs 46 | }; 47 | } 48 | }, 49 | 50 | async render() { 51 | const gameState = getCurrentGameState(); 52 | if (!gameState) return; 53 | 54 | // Make sure level state is initialized 55 | if (!gameState.levelStates[this.id]) { 56 | await this.initialize(); 57 | } 58 | 59 | const levelState = gameState.levelStates[this.id]; 60 | 61 | // Display level information and UI 62 | console.log('Your level description and UI goes here'); 63 | console.log(''); 64 | 65 | // Display available commands 66 | console.log('Commands: "command1", "command2", etc.'); 67 | }, 68 | 69 | async handleInput(input: string): Promise { 70 | const gameState = getCurrentGameState(); 71 | if (!gameState) { 72 | return { completed: false }; 73 | } 74 | 75 | // Make sure level state is initialized 76 | if (!gameState.levelStates[this.id]) { 77 | await this.initialize(); 78 | } 79 | 80 | const levelState = gameState.levelStates[this.id]; 81 | const command = input.trim(); 82 | 83 | // Split command into parts 84 | const parts = command.split(' '); 85 | const cmd = parts[0].toLowerCase(); 86 | 87 | // Handle different commands 88 | if (cmd === 'command1') { 89 | // Do something 90 | return { 91 | completed: false, 92 | message: 'Response to command1' 93 | }; 94 | } 95 | 96 | // Handle level completion 97 | if (cmd === 'win_command') { 98 | return { 99 | completed: true, 100 | message: 'Congratulations! You completed the level.', 101 | nextAction: 'next_level' 102 | }; 103 | } 104 | 105 | // Default response for unknown commands 106 | return { 107 | completed: false, 108 | message: 'Unknown command. Try something else.' 109 | }; 110 | }, 111 | 112 | hints: [ 113 | 'First hint - very subtle', 114 | 'Second hint - more direct', 115 | 'Third hint - almost gives away the solution' 116 | ] 117 | }; 118 | 119 | export function registerLevelX() { // Replace X with the level number 120 | registerLevel(level); 121 | } 122 | ```` 123 | 124 | ### Step 3: Update the levels index file 125 | 126 | Edit `src/levels/index.ts` to import and register your new level: 127 | 128 | ```typescript 129 | import { registerLevel1 } from "./level1"; 130 | import { registerLevel2 } from "./level2"; 131 | // ... other levels 132 | import { registerLevelX } from "./levelX"; // Add your new level 133 | 134 | export function registerAllLevels() { 135 | registerLevel1(); 136 | registerLevel2(); 137 | // ... other levels 138 | registerLevelX(); // Register your new level 139 | 140 | console.log("All levels registered successfully."); 141 | } 142 | ``` 143 | 144 | ## Implementing Complex Level Mechanics 145 | 146 | ### File System Navigation 147 | 148 | To implement a file system level (like Level 2), you need to: 149 | 150 | 1. Create a virtual file system structure in your level state 151 | 2. Implement commands like `ls`, `cd`, and `cat` 152 | 3. Track the current directory and handle path navigation 153 | 154 | Here's an example of a file system state structure: 155 | 156 | ```typescript 157 | // In initialize() 158 | gameState.levelStates[this.id] = { 159 | currentDir: "/home/user", 160 | fileSystem: { 161 | "/home/user": { 162 | type: "dir", 163 | contents: ["Documents", "Pictures", ".hidden"], 164 | }, 165 | "/home/user/Documents": { 166 | type: "dir", 167 | contents: ["notes.txt"], 168 | }, 169 | "/home/user/Documents/notes.txt": { 170 | type: "file", 171 | content: "This is a text file.", 172 | }, 173 | // Add more directories and files 174 | }, 175 | }; 176 | ``` 177 | 178 | Handling file system commands: 179 | 180 | ```typescript 181 | // In handleInput() 182 | if (cmd === "ls") { 183 | // List directory contents 184 | return { 185 | completed: false, 186 | message: fileSystem[levelState.currentDir].contents.join("\n"), 187 | }; 188 | } 189 | 190 | if (cmd === "cd" && parts.length > 1) { 191 | const target = parts[1]; 192 | 193 | if (target === "..") { 194 | // Go up one directory 195 | const pathParts = levelState.currentDir.split("/"); 196 | if (pathParts.length > 2) { 197 | pathParts.pop(); 198 | levelState.currentDir = pathParts.join("/"); 199 | return { 200 | completed: false, 201 | message: `Changed directory to ${levelState.currentDir}`, 202 | }; 203 | } 204 | } else { 205 | // Go to specified directory 206 | const newPath = `${levelState.currentDir}/${target}`; 207 | 208 | if (fileSystem[newPath] && fileSystem[newPath].type === "dir") { 209 | levelState.currentDir = newPath; 210 | return { 211 | completed: false, 212 | message: `Changed directory to ${levelState.currentDir}`, 213 | }; 214 | } 215 | } 216 | } 217 | 218 | if (cmd === "cat" && parts.length > 1) { 219 | const target = parts[1]; 220 | const path = `${levelState.currentDir}/${target}`; 221 | 222 | if (fileSystem[path] && fileSystem[path].type === "file") { 223 | return { 224 | completed: false, 225 | message: fileSystem[path].content, 226 | }; 227 | } 228 | } 229 | ``` 230 | 231 | ### Process Management 232 | 233 | For a process management level (like Level 3), you can: 234 | 235 | 1. Create a list of processes with properties like PID, name, CPU usage, etc. 236 | 2. Implement commands like `ps`, `kill`, and `start` 237 | 3. Track process states and check for completion conditions 238 | 239 | Example process state: 240 | 241 | ```typescript 242 | // In initialize() 243 | gameState.levelStates[this.id] = { 244 | processes: [ 245 | { pid: 1, name: "systemd", cpu: 0.1, memory: 4.2, status: "running" }, 246 | { pid: 423, name: "sshd", cpu: 0.0, memory: 1.1, status: "running" }, 247 | { 248 | pid: 842, 249 | name: "malware.bin", 250 | cpu: 99.7, 251 | memory: 85.5, 252 | status: "running", 253 | }, 254 | { pid: 1024, name: "firewall", cpu: 0.1, memory: 1.8, status: "stopped" }, 255 | ], 256 | firewallStarted: false, 257 | malwareKilled: false, 258 | }; 259 | ``` 260 | 261 | Handling process commands: 262 | 263 | ```typescript 264 | // In handleInput() 265 | if (cmd === "ps") { 266 | // Format and display process list 267 | let output = "PID NAME CPU% MEM% STATUS\n"; 268 | output += "--------------------------------------------\n"; 269 | 270 | levelState.processes.forEach((proc) => { 271 | output += `${proc.pid.toString().padEnd(7)}${proc.name.padEnd(13)}${proc.cpu 272 | .toFixed(1) 273 | .padEnd(8)}${proc.memory.toFixed(1).padEnd(8)}${proc.status}\n`; 274 | }); 275 | 276 | return { 277 | completed: false, 278 | message: output, 279 | }; 280 | } 281 | 282 | if (cmd === "kill" && parts.length > 1) { 283 | const pid = parseInt(parts[1]); 284 | const process = levelState.processes.find((p) => p.pid === pid); 285 | 286 | if (process) { 287 | process.status = "stopped"; 288 | 289 | if (process.name === "malware.bin") { 290 | levelState.malwareKilled = true; 291 | 292 | // Check if level is completed 293 | if (levelState.firewallStarted) { 294 | return { 295 | completed: true, 296 | message: "System secured! Malware stopped and firewall running.", 297 | nextAction: "next_level", 298 | }; 299 | } 300 | } 301 | } 302 | } 303 | 304 | if (cmd === "start" && parts.length > 1) { 305 | const pid = parseInt(parts[1]); 306 | const process = levelState.processes.find((p) => p.pid === pid); 307 | 308 | if (process) { 309 | process.status = "running"; 310 | 311 | if (process.name === "firewall") { 312 | levelState.firewallStarted = true; 313 | 314 | // Check if level is completed 315 | if (levelState.malwareKilled) { 316 | return { 317 | completed: true, 318 | message: "System secured! Malware stopped and firewall running.", 319 | nextAction: "next_level", 320 | }; 321 | } 322 | } 323 | } 324 | } 325 | ``` 326 | 327 | ### File Permissions 328 | 329 | For a permissions-based level (like Level 4): 330 | 331 | 1. Create files with permission attributes 332 | 2. Implement commands like `chmod` and `sudo` 333 | 3. Check permissions before allowing file access 334 | 335 | Example permissions state: 336 | 337 | ```typescript 338 | // In initialize() 339 | gameState.levelStates[this.id] = { 340 | files: [ 341 | { 342 | name: "README.txt", 343 | permissions: "rw-r--r--", 344 | owner: "user", 345 | group: "user", 346 | }, 347 | { 348 | name: "secret_data.db", 349 | permissions: "----------", 350 | owner: "root", 351 | group: "root", 352 | }, 353 | { 354 | name: "change_permissions.sh", 355 | permissions: "r--------", 356 | owner: "user", 357 | group: "user", 358 | }, 359 | ], 360 | currentUser: "user", 361 | sudoAvailable: false, 362 | }; 363 | ``` 364 | 365 | Handling permission commands: 366 | 367 | ```typescript 368 | // In handleInput() 369 | if (cmd === "cat" && parts.length > 1) { 370 | const fileName = parts[1]; 371 | const file = levelState.files.find((f) => f.name === fileName); 372 | 373 | if (file) { 374 | // Check if user has read permission 375 | const canRead = 376 | (levelState.currentUser === file.owner && file.permissions[0] === "r") || 377 | (levelState.currentUser !== file.owner && 378 | levelState.currentUser === file.group && 379 | file.permissions[3] === "r") || 380 | (levelState.currentUser !== file.owner && 381 | levelState.currentUser !== file.group && 382 | file.permissions[6] === "r") || 383 | levelState.currentUser === "root"; // root can read anything 384 | 385 | if (!canRead) { 386 | return { 387 | completed: false, 388 | message: `Permission denied: Cannot read ${fileName}`, 389 | }; 390 | } 391 | 392 | // Return file content based on the file name 393 | if (fileName === "README.txt") { 394 | return { 395 | completed: false, 396 | message: 397 | "This system contains important data. You need to access secret_data.db to proceed.", 398 | }; 399 | } 400 | // Handle other files... 401 | } 402 | } 403 | 404 | if (cmd === "chmod" && parts.length > 2) { 405 | const permissions = parts[1]; // e.g., +x 406 | const fileName = parts[2]; 407 | const file = levelState.files.find((f) => f.name === fileName); 408 | 409 | if (file) { 410 | // Check if user owns the file 411 | if ( 412 | levelState.currentUser !== file.owner && 413 | levelState.currentUser !== "root" 414 | ) { 415 | return { 416 | completed: false, 417 | message: `Permission denied: Only the owner can change permissions of ${fileName}`, 418 | }; 419 | } 420 | 421 | // Handle different chmod formats 422 | if (permissions === "+x") { 423 | // Make file executable 424 | let newPermissions = file.permissions.split(""); 425 | newPermissions[2] = "x"; // Owner execute 426 | file.permissions = newPermissions.join(""); 427 | 428 | if (fileName === "change_permissions.sh") { 429 | levelState.scriptExecutable = true; 430 | } 431 | 432 | return { 433 | completed: false, 434 | message: `Changed permissions of ${fileName} to ${file.permissions}`, 435 | }; 436 | } 437 | // Handle other permission changes... 438 | } 439 | } 440 | ``` 441 | 442 | ### Network Configuration 443 | 444 | For a network-based level (like Level 5): 445 | 446 | 1. Create network interfaces, firewall rules, and DNS settings 447 | 2. Implement commands like `ifconfig`, `ping`, and `firewall-cmd` 448 | 3. Track network state and check for connectivity 449 | 450 | Example network state: 451 | 452 | ```typescript 453 | // In initialize() 454 | gameState.levelStates[this.id] = { 455 | interfaces: [ 456 | { name: "lo", status: "UP", ip: "127.0.0.1", netmask: "255.0.0.0" }, 457 | { name: "eth0", status: "DOWN", ip: "", netmask: "" }, 458 | ], 459 | firewall: { 460 | enabled: true, 461 | rules: [ 462 | { port: 80, protocol: "tcp", action: "DENY" }, 463 | { port: 443, protocol: "tcp", action: "DENY" }, 464 | ], 465 | }, 466 | dns: { 467 | configured: false, 468 | server: "", 469 | }, 470 | gateway: { 471 | configured: false, 472 | address: "", 473 | }, 474 | }; 475 | ``` 476 | 477 | Handling network commands: 478 | 479 | ```typescript 480 | // In handleInput() 481 | if (cmd === "ifconfig") { 482 | if (parts.length === 1) { 483 | // Show all interfaces 484 | let output = "Network Interfaces:\n"; 485 | output += "NAME STATUS IP NETMASK\n"; 486 | output += "----------------------------------------\n"; 487 | 488 | levelState.interfaces.forEach((iface) => { 489 | output += `${iface.name.padEnd(7)}${iface.status.padEnd( 490 | 9 491 | )}${iface.ip.padEnd(14)}${iface.netmask}\n`; 492 | }); 493 | 494 | return { 495 | completed: false, 496 | message: output, 497 | }; 498 | } else if (parts.length >= 4) { 499 | // Configure an interface 500 | const ifaceName = parts[1]; 501 | const ip = parts[2]; 502 | const netmask = parts[3]; 503 | 504 | const iface = levelState.interfaces.find((i) => i.name === ifaceName); 505 | 506 | if (iface) { 507 | iface.ip = ip; 508 | iface.netmask = netmask; 509 | 510 | return { 511 | completed: false, 512 | message: `Configured ${ifaceName} with IP ${ip} and netmask ${netmask}.`, 513 | }; 514 | } 515 | } 516 | } 517 | 518 | if (cmd === "ifup" && parts.length > 1) { 519 | const ifaceName = parts[1]; 520 | const iface = levelState.interfaces.find((i) => i.name === ifaceName); 521 | 522 | if (iface) { 523 | iface.status = "UP"; 524 | 525 | return { 526 | completed: false, 527 | message: `Interface ${ifaceName} is now UP.`, 528 | }; 529 | } 530 | } 531 | 532 | if (cmd === "firewall-cmd") { 533 | if (parts.includes("--disable")) { 534 | levelState.firewall.enabled = false; 535 | 536 | return { 537 | completed: false, 538 | message: "Firewall disabled.", 539 | }; 540 | } 541 | 542 | if (parts.includes("--allow") && parts.length > 2) { 543 | const port = parseInt(parts[parts.indexOf("--allow") + 1]); 544 | 545 | // Find the rule for this port 546 | const rule = levelState.firewall.rules.find((r) => r.port === port); 547 | 548 | if (rule) { 549 | rule.action = "ALLOW"; 550 | 551 | return { 552 | completed: false, 553 | message: `Port ${port} is now allowed through the firewall.`, 554 | }; 555 | } 556 | } 557 | } 558 | ``` 559 | 560 | ## Using the Enhanced UI 561 | 562 | The game includes a `levelUI` helper in `src/ui/levelRenderer.ts` that provides styled UI components for your levels: 563 | 564 | ```typescript 565 | import { levelUI } from "../ui/levelRenderer"; 566 | 567 | // In render() 568 | levelUI.title("Welcome to My Level"); 569 | levelUI.paragraph("This is a description of the level."); 570 | levelUI.spacer(); 571 | 572 | // Display a terminal 573 | levelUI.terminal( 574 | "$ ls -la\ntotal 12\ndrwxr-xr-x 2 user user 4096 Jan 1 12:00 ." 575 | ); 576 | 577 | // Display a file system 578 | const items = [ 579 | { name: "Documents", type: "dir" }, 580 | { name: "notes.txt", type: "file" }, 581 | ]; 582 | levelUI.fileSystem("/home/user", items); 583 | 584 | // Display a process table 585 | levelUI.processTable(levelState.processes); 586 | 587 | // Display available commands 588 | levelUI.commands(["ls", "cd [dir]", "cat [file]"]); 589 | ``` 590 | 591 | ## Level State Management 592 | 593 | The level state is stored in `gameState.levelStates[levelId]`. This is where you should store any level-specific data that needs to persist between renders or commands. 594 | 595 | ## Level Results 596 | 597 | When handling user input, your level should return a `LevelResult` object: 598 | 599 | ```typescript 600 | interface LevelResult { 601 | completed: boolean; // Whether the level is completed 602 | message?: string; // Optional message to display to the user 603 | nextAction?: "next_level" | "main_menu" | "continue"; // What to do next 604 | } 605 | ``` 606 | 607 | - Set `completed: true` when the player solves the level 608 | - Use `nextAction: 'next_level'` to proceed to the next level 609 | - Use `nextAction: 'main_menu'` to return to the main menu 610 | - Use `nextAction: 'continue'` or omit to stay in the current level 611 | 612 | ## Best Practices 613 | 614 | 1. **Initialization**: Always check if the level state exists and initialize it if not 615 | 2. **Progressive Difficulty**: Make your level challenging but fair 616 | 3. **Clear Instructions**: Make sure players understand what they need to do 617 | 4. **Meaningful Feedback**: Provide helpful responses to player commands 618 | 5. **Multiple Solutions**: When possible, allow multiple ways to solve the puzzle 619 | 6. **Thematic Consistency**: Try to maintain the Linux/tech theme of the game 620 | 7. **Hints**: Provide at least 3 hints of increasing specificity 621 | 622 | ## Example Level Ideas 623 | 624 | - **Network Security**: Configure a firewall to block specific attacks 625 | - **Cryptography**: Decode encrypted messages using various ciphers 626 | - **Database Challenge**: Use SQL-like commands to extract information 627 | - **Git Simulation**: Navigate and manipulate a git repository 628 | - **Container Escape**: Escape from a simulated container environment 629 | 630 | ## Testing Your Level 631 | 632 | Always test your level thoroughly to ensure: 633 | 634 | - It can be completed 635 | - All commands work as expected 636 | - The level state initializes correctly 637 | - Transitions to the next level work properly 638 | 639 | Happy level creating! 640 | 641 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShellQuest 2 | 3 | ![screenshot](./screenshot.png) 4 | ShellQuest is an expandable, hackable and open source interactive text-based escape room game and engine that challenges players to solve puzzles using Linux terminal commands and concepts. 5 | 6 | The main aim of ShellQuest is to be expanded by the users that can then share their levels with others. 7 | 8 | ## Introduction 9 | 10 | In ShellQuest, you'll navigate through a series of increasingly difficult levels, each requiring you to use your knowledge of Linux commands and problem-solving skills. Whether you're a Linux expert or a beginner, the game provides an engaging way to learn and practice terminal skills in a fun, gamified environment. 11 | 12 | ## Features 13 | 14 | - Multiple challenging levels with increasing difficulty 15 | - Linux terminal simulation with common commands 16 | - Player profiles and save system 17 | - Achievement system to track your progress 18 | - Helpful hints when you get stuck 19 | - Progress map to visualize your journey 20 | - Leaderboard to compare your performance 21 | 22 | ## Installation 23 | 24 | 1. Make sure you have [Bun](https://bun.sh/) installed on your system 25 | 2. Clone this repository 26 | 3. Run `bun install` to install dependencies 27 | 4. Run `bun run dev` to start the game 28 | 29 | ## Getting Started 30 | 31 | 1. Start the game with `bun run dev` 32 | 2. Create a new profile or load an existing one 33 | 3. Follow the in-game instructions to begin your first level 34 | 4. Use Linux commands to solve puzzles and progress through levels 35 | 36 | ## How to Play 37 | 38 | - Use Linux commands to interact with the game environment 39 | - Type `/help` at any time to see available commands 40 | - Use `/hint` if you get stuck on a level 41 | - Use `/save` to save your progress 42 | - Use `/menu` to return to the main menu 43 | 44 | ## Save System 45 | 46 | The game automatically saves your progress as you complete levels. Each player has their own profile that stores: 47 | - Current level progress 48 | - Achievements unlocked 49 | - Game statistics 50 | 51 | You can have multiple profiles for different players on the same computer. 52 | 53 | ## Achievements 54 | 55 | Unlock achievements by completing specific challenges: 56 | - Completing levels 57 | - Using specific commands 58 | - Finding hidden secrets 59 | - And more! 60 | 61 | Check your achievements from the main menu to track your progress. 62 | 63 | ## Creating New Levels 64 | 65 | ShellQuest is designed to be easily expandable with new levels. If you're interested in creating your own levels, check out the [LEVELS.md](LEVELS.md) guide in the repository. 66 | 67 | ## Troubleshooting 68 | 69 | - **Game won't start**: Make sure Bun is installed correctly and you've run `bun install` 70 | - **Can't save progress**: Check that your user has write permissions to the saves directory 71 | - **Commands not working**: Make sure you're using the correct syntax for the level 72 | 73 | ## Game Structure 74 | 75 | The game is organized into several core components: 76 | 77 | - **Core System**: Game state management, level system, achievements 78 | - **UI**: Terminal interface, visual effects, menus 79 | - **Levels**: Individual puzzle challenges 80 | 81 | ## Contributing 82 | 83 | Contributions are welcome! Feel free to: 84 | 85 | - Create new levels 86 | - Fix bugs 87 | - Improve the UI 88 | - Add new features 89 | 90 | Please follow the existing code style and structure when making contributions. 91 | 92 | ## License 93 | 94 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 95 | 96 | ## Acknowledgments 97 | 98 | - Inspired by Linux terminal escape rooms and CTF challenges 99 | - Built with TypeScript and Bun 100 | - Special thanks to all contributors 101 | -------------------------------------------------------------------------------- /achievements.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "first_steps", 4 | "name": "First Steps", 5 | "description": "Complete your first level", 6 | "icon": "🏆", 7 | "unlocked": false 8 | }, 9 | { 10 | "id": "speed_demon", 11 | "name": "Speed Demon", 12 | "description": "Complete a level in under 60 seconds", 13 | "icon": "⚡", 14 | "unlocked": false 15 | }, 16 | { 17 | "id": "no_hints", 18 | "name": "Solo Hacker", 19 | "description": "Complete a level without using hints", 20 | "icon": "🧠", 21 | "unlocked": false 22 | }, 23 | { 24 | "id": "command_master", 25 | "name": "Command Master", 26 | "description": "Use at least 10 different commands in one level", 27 | "icon": "💻", 28 | "unlocked": false 29 | }, 30 | { 31 | "id": "persistence", 32 | "name": "Persistence", 33 | "description": "Try at least 20 commands in a single level", 34 | "icon": "🔨", 35 | "unlocked": false 36 | }, 37 | { 38 | "id": "explorer", 39 | "name": "Explorer", 40 | "description": "Visit all directories in a file system level", 41 | "icon": "🧭", 42 | "unlocked": false 43 | }, 44 | { 45 | "id": "easter_egg_hunter", 46 | "name": "Easter Egg Hunter", 47 | "description": "Find a hidden secret", 48 | "icon": "🥚", 49 | "secret": true, 50 | "unlocked": false 51 | }, 52 | { 53 | "id": "master_hacker", 54 | "name": "Master Hacker", 55 | "description": "Complete the game", 56 | "icon": "👑", 57 | "unlocked": false 58 | } 59 | ] -------------------------------------------------------------------------------- /leaderboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "players": [] 3 | } -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terminal-escape", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "terminal-escape", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "beep": "^0.0.0", 13 | "figlet": "^1.6.0", 14 | "figlet-cli": "^0.2.0", 15 | "gradient-string": "^2.0.2", 16 | "kleur": "^4.1.5", 17 | "play-sound": "^1.1.6" 18 | }, 19 | "devDependencies": { 20 | "bun-types": "latest" 21 | } 22 | }, 23 | "node_modules/@types/node": { 24 | "version": "22.14.1", 25 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", 26 | "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", 27 | "dev": true, 28 | "dependencies": { 29 | "undici-types": "~6.21.0" 30 | } 31 | }, 32 | "node_modules/@types/tinycolor2": { 33 | "version": "1.4.6", 34 | "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", 35 | "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==" 36 | }, 37 | "node_modules/ansi-styles": { 38 | "version": "4.3.0", 39 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 40 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 41 | "dependencies": { 42 | "color-convert": "^2.0.1" 43 | }, 44 | "engines": { 45 | "node": ">=8" 46 | }, 47 | "funding": { 48 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 49 | } 50 | }, 51 | "node_modules/beep": { 52 | "version": "0.0.0", 53 | "resolved": "https://registry.npmjs.org/beep/-/beep-0.0.0.tgz", 54 | "integrity": "sha512-L/SvbLk1oTwfu7ipLpO8CJURqDCAFFXfLJwYIkDlFnQ1Wd+Fgje8OfTdy5HNqqaNeqZJ9Bbqk4UgXmDauCBjNQ==", 55 | "engines": { 56 | "node": ">=0.6.0" 57 | } 58 | }, 59 | "node_modules/bun-types": { 60 | "version": "1.2.10", 61 | "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.10.tgz", 62 | "integrity": "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ==", 63 | "dev": true, 64 | "dependencies": { 65 | "@types/node": "*" 66 | } 67 | }, 68 | "node_modules/chalk": { 69 | "version": "4.1.2", 70 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 71 | "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 72 | "dependencies": { 73 | "ansi-styles": "^4.1.0", 74 | "supports-color": "^7.1.0" 75 | }, 76 | "engines": { 77 | "node": ">=10" 78 | }, 79 | "funding": { 80 | "url": "https://github.com/chalk/chalk?sponsor=1" 81 | } 82 | }, 83 | "node_modules/color-convert": { 84 | "version": "2.0.1", 85 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 86 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 87 | "dependencies": { 88 | "color-name": "~1.1.4" 89 | }, 90 | "engines": { 91 | "node": ">=7.0.0" 92 | } 93 | }, 94 | "node_modules/color-name": { 95 | "version": "1.1.4", 96 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 97 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 98 | }, 99 | "node_modules/figlet": { 100 | "version": "1.8.1", 101 | "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.1.tgz", 102 | "integrity": "sha512-kEC3Sme+YvA8Hkibv0NR1oClGcWia0VB2fC1SlMy027cwe795Xx40Xiv/nw/iFAwQLupymWh+uhAAErn/7hwPg==", 103 | "bin": { 104 | "figlet": "bin/index.js" 105 | }, 106 | "engines": { 107 | "node": ">= 0.4.0" 108 | } 109 | }, 110 | "node_modules/figlet-cli": { 111 | "version": "0.2.0", 112 | "resolved": "https://registry.npmjs.org/figlet-cli/-/figlet-cli-0.2.0.tgz", 113 | "integrity": "sha512-HjkrRHJL8SblWMX7tJqphsA88eGf/JQYJph9pRWkXDZMyiGqQZUTY3Eu9u+bn5h9ynWRfyKbvvTZSZZd5JhhfQ==", 114 | "dependencies": { 115 | "figlet": "^1.8.0", 116 | "optimist": "~0.6.0" 117 | }, 118 | "bin": { 119 | "figlet": "bin/figlet" 120 | }, 121 | "engines": { 122 | "node": ">= 0.4.0" 123 | } 124 | }, 125 | "node_modules/find-exec": { 126 | "version": "1.0.3", 127 | "resolved": "https://registry.npmjs.org/find-exec/-/find-exec-1.0.3.tgz", 128 | "integrity": "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug==", 129 | "dependencies": { 130 | "shell-quote": "^1.8.1" 131 | } 132 | }, 133 | "node_modules/gradient-string": { 134 | "version": "2.0.2", 135 | "resolved": "https://registry.npmjs.org/gradient-string/-/gradient-string-2.0.2.tgz", 136 | "integrity": "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw==", 137 | "dependencies": { 138 | "chalk": "^4.1.2", 139 | "tinygradient": "^1.1.5" 140 | }, 141 | "engines": { 142 | "node": ">=10" 143 | } 144 | }, 145 | "node_modules/has-flag": { 146 | "version": "4.0.0", 147 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 148 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 149 | "engines": { 150 | "node": ">=8" 151 | } 152 | }, 153 | "node_modules/kleur": { 154 | "version": "4.1.5", 155 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 156 | "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", 157 | "engines": { 158 | "node": ">=6" 159 | } 160 | }, 161 | "node_modules/minimist": { 162 | "version": "0.0.10", 163 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", 164 | "integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==" 165 | }, 166 | "node_modules/optimist": { 167 | "version": "0.6.1", 168 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 169 | "integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==", 170 | "dependencies": { 171 | "minimist": "~0.0.1", 172 | "wordwrap": "~0.0.2" 173 | } 174 | }, 175 | "node_modules/play-sound": { 176 | "version": "1.1.6", 177 | "resolved": "https://registry.npmjs.org/play-sound/-/play-sound-1.1.6.tgz", 178 | "integrity": "sha512-09eO4QiXNFXJffJaOW5P6x6F5RLihpLUkXttvUZeWml0fU6x6Zp7AjG9zaeMpgH2ZNvq4GR1ytB22ddYcqJIZA==", 179 | "dependencies": { 180 | "find-exec": "1.0.3" 181 | } 182 | }, 183 | "node_modules/shell-quote": { 184 | "version": "1.8.2", 185 | "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", 186 | "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", 187 | "engines": { 188 | "node": ">= 0.4" 189 | }, 190 | "funding": { 191 | "url": "https://github.com/sponsors/ljharb" 192 | } 193 | }, 194 | "node_modules/supports-color": { 195 | "version": "7.2.0", 196 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 197 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 198 | "dependencies": { 199 | "has-flag": "^4.0.0" 200 | }, 201 | "engines": { 202 | "node": ">=8" 203 | } 204 | }, 205 | "node_modules/tinycolor2": { 206 | "version": "1.6.0", 207 | "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", 208 | "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" 209 | }, 210 | "node_modules/tinygradient": { 211 | "version": "1.1.5", 212 | "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", 213 | "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", 214 | "dependencies": { 215 | "@types/tinycolor2": "^1.4.0", 216 | "tinycolor2": "^1.0.0" 217 | } 218 | }, 219 | "node_modules/undici-types": { 220 | "version": "6.21.0", 221 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 222 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 223 | "dev": true 224 | }, 225 | "node_modules/wordwrap": { 226 | "version": "0.0.3", 227 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", 228 | "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==", 229 | "engines": { 230 | "node": ">=0.4.0" 231 | } 232 | } 233 | }, 234 | "dependencies": { 235 | "@types/node": { 236 | "version": "22.14.1", 237 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", 238 | "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", 239 | "dev": true, 240 | "requires": { 241 | "undici-types": "~6.21.0" 242 | } 243 | }, 244 | "@types/tinycolor2": { 245 | "version": "1.4.6", 246 | "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", 247 | "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==" 248 | }, 249 | "ansi-styles": { 250 | "version": "4.3.0", 251 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 252 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 253 | "requires": { 254 | "color-convert": "^2.0.1" 255 | } 256 | }, 257 | "beep": { 258 | "version": "0.0.0", 259 | "resolved": "https://registry.npmjs.org/beep/-/beep-0.0.0.tgz", 260 | "integrity": "sha512-L/SvbLk1oTwfu7ipLpO8CJURqDCAFFXfLJwYIkDlFnQ1Wd+Fgje8OfTdy5HNqqaNeqZJ9Bbqk4UgXmDauCBjNQ==" 261 | }, 262 | "bun-types": { 263 | "version": "1.2.10", 264 | "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.10.tgz", 265 | "integrity": "sha512-b5ITZMnVdf3m1gMvJHG+gIfeJHiQPJak0f7925Hxu6ZN5VKA8AGy4GZ4lM+Xkn6jtWxg5S3ldWvfmXdvnkp3GQ==", 266 | "dev": true, 267 | "requires": { 268 | "@types/node": "*" 269 | } 270 | }, 271 | "chalk": { 272 | "version": "4.1.2", 273 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 274 | "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 275 | "requires": { 276 | "ansi-styles": "^4.1.0", 277 | "supports-color": "^7.1.0" 278 | } 279 | }, 280 | "color-convert": { 281 | "version": "2.0.1", 282 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 283 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 284 | "requires": { 285 | "color-name": "~1.1.4" 286 | } 287 | }, 288 | "color-name": { 289 | "version": "1.1.4", 290 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 291 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 292 | }, 293 | "figlet": { 294 | "version": "1.8.1", 295 | "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.8.1.tgz", 296 | "integrity": "sha512-kEC3Sme+YvA8Hkibv0NR1oClGcWia0VB2fC1SlMy027cwe795Xx40Xiv/nw/iFAwQLupymWh+uhAAErn/7hwPg==" 297 | }, 298 | "figlet-cli": { 299 | "version": "0.2.0", 300 | "resolved": "https://registry.npmjs.org/figlet-cli/-/figlet-cli-0.2.0.tgz", 301 | "integrity": "sha512-HjkrRHJL8SblWMX7tJqphsA88eGf/JQYJph9pRWkXDZMyiGqQZUTY3Eu9u+bn5h9ynWRfyKbvvTZSZZd5JhhfQ==", 302 | "requires": { 303 | "figlet": "^1.8.0", 304 | "optimist": "~0.6.0" 305 | } 306 | }, 307 | "find-exec": { 308 | "version": "1.0.3", 309 | "resolved": "https://registry.npmjs.org/find-exec/-/find-exec-1.0.3.tgz", 310 | "integrity": "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug==", 311 | "requires": { 312 | "shell-quote": "^1.8.1" 313 | } 314 | }, 315 | "gradient-string": { 316 | "version": "2.0.2", 317 | "resolved": "https://registry.npmjs.org/gradient-string/-/gradient-string-2.0.2.tgz", 318 | "integrity": "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw==", 319 | "requires": { 320 | "chalk": "^4.1.2", 321 | "tinygradient": "^1.1.5" 322 | } 323 | }, 324 | "has-flag": { 325 | "version": "4.0.0", 326 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 327 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" 328 | }, 329 | "kleur": { 330 | "version": "4.1.5", 331 | "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 332 | "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" 333 | }, 334 | "minimist": { 335 | "version": "0.0.10", 336 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", 337 | "integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==" 338 | }, 339 | "optimist": { 340 | "version": "0.6.1", 341 | "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", 342 | "integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==", 343 | "requires": { 344 | "minimist": "~0.0.1", 345 | "wordwrap": "~0.0.2" 346 | } 347 | }, 348 | "play-sound": { 349 | "version": "1.1.6", 350 | "resolved": "https://registry.npmjs.org/play-sound/-/play-sound-1.1.6.tgz", 351 | "integrity": "sha512-09eO4QiXNFXJffJaOW5P6x6F5RLihpLUkXttvUZeWml0fU6x6Zp7AjG9zaeMpgH2ZNvq4GR1ytB22ddYcqJIZA==", 352 | "requires": { 353 | "find-exec": "1.0.3" 354 | } 355 | }, 356 | "shell-quote": { 357 | "version": "1.8.2", 358 | "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", 359 | "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==" 360 | }, 361 | "supports-color": { 362 | "version": "7.2.0", 363 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 364 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 365 | "requires": { 366 | "has-flag": "^4.0.0" 367 | } 368 | }, 369 | "tinycolor2": { 370 | "version": "1.6.0", 371 | "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", 372 | "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" 373 | }, 374 | "tinygradient": { 375 | "version": "1.1.5", 376 | "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", 377 | "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", 378 | "requires": { 379 | "@types/tinycolor2": "^1.4.0", 380 | "tinycolor2": "^1.0.0" 381 | } 382 | }, 383 | "undici-types": { 384 | "version": "6.21.0", 385 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 386 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 387 | "dev": true 388 | }, 389 | "wordwrap": { 390 | "version": "0.0.3", 391 | "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", 392 | "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==" 393 | } 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terminal-escape", 3 | "version": "1.0.0", 4 | "description": "A Linux terminal escape room game", 5 | "main": "src/index.ts", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node -e \"console.log('\\x1b[2J\\x1b[0f\\x1b[36m\\n Launching Terminal Escape...\\n\\x1b[0m'); setTimeout(() => {}, 1000)\" && bun src/index.ts", 9 | "build": "bun build src/index.ts --outdir ./dist", 10 | "dev": "bun --watch src/index.ts" 11 | }, 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "bun-types": "latest" 16 | }, 17 | "dependencies": { 18 | "beep": "^0.0.0", 19 | "figlet": "^1.6.0", 20 | "figlet-cli": "^0.2.0", 21 | "gradient-string": "^2.0.2", 22 | "kleur": "^4.1.5", 23 | "play-sound": "^1.1.6" 24 | } 25 | } -------------------------------------------------------------------------------- /saves/placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tcsenpai/shellquest/e98a41627739dd0fbe128fdf7d3e00f81fd19693/saves/placeholder -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tcsenpai/shellquest/e98a41627739dd0fbe128fdf7d3e00f81fd19693/screenshot.png -------------------------------------------------------------------------------- /src/core/achievements.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import { getCurrentGameState } from './gameState'; 4 | import { getTheme, successAnimation } from '../ui/visualEffects'; 5 | import { playSound } from '../ui/soundEffects'; 6 | import { getCurrentProfile } from './playerProfile'; 7 | import { saveProfile } from './playerProfile'; 8 | 9 | // Define achievement types 10 | export interface Achievement { 11 | id: string; 12 | name: string; 13 | description: string; 14 | icon: string; 15 | secret?: boolean; 16 | unlocked: boolean; 17 | unlockedAt?: number; 18 | } 19 | 20 | // Define all achievements 21 | export const achievements: Achievement[] = [ 22 | { 23 | id: 'first_steps', 24 | name: 'First Steps', 25 | description: 'Complete your first level', 26 | icon: '🏆', 27 | unlocked: false 28 | }, 29 | { 30 | id: 'speed_demon', 31 | name: 'Speed Demon', 32 | description: 'Complete a level in under 60 seconds', 33 | icon: '⚡', 34 | unlocked: false 35 | }, 36 | { 37 | id: 'no_hints', 38 | name: 'Solo Hacker', 39 | description: 'Complete a level without using hints', 40 | icon: '🧠', 41 | unlocked: false 42 | }, 43 | { 44 | id: 'command_master', 45 | name: 'Command Master', 46 | description: 'Use at least 10 different commands in one level', 47 | icon: '💻', 48 | unlocked: false 49 | }, 50 | { 51 | id: 'persistence', 52 | name: 'Persistence', 53 | description: 'Try at least 20 commands in a single level', 54 | icon: '🔨', 55 | unlocked: false 56 | }, 57 | { 58 | id: 'explorer', 59 | name: 'Explorer', 60 | description: 'Visit all directories in a file system level', 61 | icon: '🧭', 62 | unlocked: false 63 | }, 64 | { 65 | id: 'easter_egg_hunter', 66 | name: 'Easter Egg Hunter', 67 | description: 'Find a hidden secret', 68 | icon: '🥚', 69 | secret: true, 70 | unlocked: false 71 | }, 72 | { 73 | id: 'master_hacker', 74 | name: 'Master Hacker', 75 | description: 'Complete the game', 76 | icon: '👑', 77 | unlocked: false 78 | } 79 | ]; 80 | 81 | // Path to achievements file 82 | const achievementsPath = path.join(process.cwd(), 'achievements.json'); 83 | 84 | // Load achievements from file 85 | export async function loadAchievements(): Promise { 86 | const profile = await getCurrentProfile(); 87 | 88 | if (profile && profile.achievements.length > 0) { 89 | return profile.achievements; 90 | } 91 | 92 | // Return default achievements if no profile exists or no achievements in profile 93 | return [...achievements]; 94 | } 95 | 96 | // Save achievements to file 97 | export async function saveAchievements(achievements: Achievement[]): Promise { 98 | try { 99 | await fs.writeFile(achievementsPath, JSON.stringify(achievements, null, 2)); 100 | } catch (error) { 101 | console.error('Error saving achievements:', error); 102 | } 103 | } 104 | 105 | // Get player achievements 106 | export async function getPlayerAchievements(playerName: string): Promise { 107 | const allAchievements = await loadAchievements(); 108 | 109 | // Filter achievements for this player (in a real game, you'd store player-specific achievements) 110 | return allAchievements; 111 | } 112 | 113 | // Unlock an achievement 114 | export async function unlockAchievement(id: string): Promise { 115 | const profile = await getCurrentProfile(); 116 | if (!profile) return false; 117 | 118 | // Find the achievement in the profile 119 | let achievementIndex = profile.achievements.findIndex(a => a.id === id); 120 | 121 | // If not found in profile, check the default achievements 122 | if (achievementIndex === -1) { 123 | const defaultAchievement = achievements.find(a => a.id === id); 124 | if (!defaultAchievement) return false; 125 | 126 | // Add the achievement to the profile 127 | profile.achievements.push({ 128 | ...defaultAchievement, 129 | unlocked: true, 130 | unlockedAt: Date.now() 131 | }); 132 | } else { 133 | // Achievement already exists in profile, just update it 134 | if (profile.achievements[achievementIndex].unlocked) { 135 | return false; // Already unlocked 136 | } 137 | 138 | profile.achievements[achievementIndex].unlocked = true; 139 | profile.achievements[achievementIndex].unlockedAt = Date.now(); 140 | } 141 | 142 | // Save the updated profile 143 | await saveProfile(profile); 144 | 145 | // Show achievement notification 146 | const achievement = profile.achievements.find(a => a.id === id); 147 | if (achievement) { 148 | await showAchievementNotification(achievement); 149 | } 150 | 151 | return true; 152 | } 153 | 154 | export async function showAchievementNotification(achievement: Achievement): Promise { 155 | const theme = getTheme(); 156 | const profile = await getCurrentProfile(); 157 | 158 | if (!profile) return; 159 | const message = `${theme.success('Achievement Unlocked!')} ${achievement.icon} ${theme.accent(achievement.name)} - ${achievement.description}`; 160 | console.log(message); 161 | console.log(theme.success('Achievement Unlocked!')); 162 | } 163 | 164 | // Check and potentially unlock achievements based on game events 165 | export async function checkAchievements(event: string, data?: any): Promise { 166 | const gameState = getCurrentGameState(); 167 | if (!gameState) return; 168 | 169 | switch (event) { 170 | case 'level_completed': 171 | // First Steps achievement 172 | await unlockAchievement('first_steps'); 173 | 174 | // Speed Demon achievement 175 | const levelState = gameState.levelStates[gameState.currentLevel]; 176 | if (levelState && levelState.startTime) { 177 | const completionTime = Date.now() - levelState.startTime; 178 | if (completionTime < 60000) { // Less than 60 seconds 179 | await unlockAchievement('speed_demon'); 180 | } 181 | } 182 | 183 | // No Hints achievement 184 | if (levelState && !levelState.usedHint) { 185 | await unlockAchievement('no_hints'); 186 | } 187 | 188 | // Command Master achievement 189 | if (levelState && levelState.uniqueCommands && levelState.uniqueCommands.size >= 10) { 190 | await unlockAchievement('command_master'); 191 | } 192 | 193 | // Persistence achievement 194 | if (levelState && levelState.commandCount && levelState.commandCount >= 20) { 195 | await unlockAchievement('persistence'); 196 | } 197 | 198 | // Master Hacker achievement (game completed) 199 | const allLevels = data?.allLevels || []; 200 | if (gameState.currentLevel >= allLevels.length) { 201 | await unlockAchievement('master_hacker'); 202 | } 203 | break; 204 | 205 | case 'hint_used': 206 | // Mark that hints were used for this level 207 | const currentLevel = gameState.currentLevel; 208 | if (!gameState.levelStates[currentLevel]) { 209 | gameState.levelStates[currentLevel] = {}; 210 | } 211 | gameState.levelStates[currentLevel].usedHint = true; 212 | break; 213 | 214 | case 'command_used': 215 | // Track unique commands used 216 | const level = gameState.currentLevel; 217 | if (!gameState.levelStates[level]) { 218 | gameState.levelStates[level] = {}; 219 | } 220 | 221 | if (!gameState.levelStates[level].uniqueCommands) { 222 | gameState.levelStates[level].uniqueCommands = new Set(); 223 | } 224 | 225 | if (!gameState.levelStates[level].commandCount) { 226 | gameState.levelStates[level].commandCount = 0; 227 | } 228 | 229 | gameState.levelStates[level].uniqueCommands.add(data.command); 230 | gameState.levelStates[level].commandCount++; 231 | break; 232 | 233 | case 'easter_egg_found': 234 | await unlockAchievement('easter_egg_hunter'); 235 | break; 236 | 237 | case 'all_directories_visited': 238 | await unlockAchievement('explorer'); 239 | break; 240 | } 241 | } 242 | 243 | // Display achievements screen 244 | export async function showAchievements(): Promise { 245 | const gameState = getCurrentGameState(); 246 | if (!gameState) return; 247 | 248 | const theme = getTheme(); 249 | const allAchievements = await loadAchievements(); 250 | 251 | console.clear(); 252 | console.log(theme.accent('=== Achievements ===')); 253 | console.log(''); 254 | 255 | // Group achievements by unlocked status 256 | const unlockedAchievements = allAchievements.filter(a => a.unlocked); 257 | const lockedAchievements = allAchievements.filter(a => !a.unlocked && !a.secret); 258 | const secretAchievements = allAchievements.filter(a => !a.unlocked && a.secret); 259 | 260 | // Display unlocked achievements 261 | console.log(theme.success('Unlocked Achievements:')); 262 | if (unlockedAchievements.length === 0) { 263 | console.log(' None yet. Keep playing!'); 264 | } else { 265 | unlockedAchievements.forEach(a => { 266 | console.log(` ${a.icon} ${theme.accent(a.name)} - ${a.description}`); 267 | }); 268 | } 269 | 270 | console.log(''); 271 | 272 | // Display locked achievements 273 | console.log(theme.secondary('Locked Achievements:')); 274 | if (lockedAchievements.length === 0) { 275 | console.log(' You\'ve unlocked all regular achievements!'); 276 | } else { 277 | lockedAchievements.forEach(a => { 278 | console.log(` ${a.icon} ${theme.accent(a.name)} - ${a.description}`); 279 | }); 280 | } 281 | 282 | console.log(''); 283 | 284 | // Display secret achievements (just show that they exist) 285 | console.log(theme.warning('Secret Achievements:')); 286 | secretAchievements.forEach(a => { 287 | console.log(` ${a.icon} ${theme.accent('???')} - Find this secret achievement!`); 288 | }); 289 | 290 | console.log(''); 291 | console.log(`Total Progress: ${unlockedAchievements.length}/${allAchievements.length} achievements unlocked`); 292 | } 293 | 294 | // Add this function to the achievements.ts file 295 | export async function triggerAchievement( 296 | eventType: 'level_completed' | 'hint_used' | 'command_used' | 'easter_egg_found' | 'all_directories_visited', 297 | data: any = {} 298 | ): Promise { 299 | const gameState = getCurrentGameState(); 300 | if (!gameState) return; 301 | 302 | // Process the event and check for achievements 303 | switch (eventType) { 304 | case 'level_completed': 305 | // First level completion 306 | if (data.levelId === 1) { 307 | await unlockAchievement('first_steps'); 308 | } 309 | 310 | // Complete a level quickly 311 | if (data.timeSpent && data.timeSpent < 60) { 312 | await unlockAchievement('speed_demon'); 313 | } 314 | 315 | // Complete a level without hints 316 | if (!data.usedHint) { 317 | await unlockAchievement('no_hints'); 318 | } 319 | 320 | // Complete all levels 321 | if (data.levelId === data.allLevels) { 322 | await unlockAchievement('master_hacker'); 323 | } 324 | break; 325 | 326 | case 'hint_used': 327 | // Track hint usage 328 | const currentLevel = gameState.currentLevel; 329 | if (!gameState.levelStates[currentLevel]) { 330 | gameState.levelStates[currentLevel] = {}; 331 | } 332 | gameState.levelStates[currentLevel].usedHint = true; 333 | break; 334 | 335 | case 'command_used': 336 | // Track unique commands used 337 | const level = gameState.currentLevel; 338 | if (!gameState.levelStates[level]) { 339 | gameState.levelStates[level] = {}; 340 | } 341 | 342 | if (!gameState.levelStates[level].uniqueCommands) { 343 | gameState.levelStates[level].uniqueCommands = new Set(); 344 | } 345 | 346 | if (!gameState.levelStates[level].commandCount) { 347 | gameState.levelStates[level].commandCount = 0; 348 | } 349 | 350 | gameState.levelStates[level].uniqueCommands.add(data.command); 351 | gameState.levelStates[level].commandCount++; 352 | 353 | // Check for command master achievement 354 | if (gameState.levelStates[level].uniqueCommands.size >= 10) { 355 | await unlockAchievement('command_master'); 356 | } 357 | 358 | // Check for persistence achievement 359 | if (gameState.levelStates[level].commandCount >= 20) { 360 | await unlockAchievement('persistence'); 361 | } 362 | break; 363 | 364 | case 'easter_egg_found': 365 | await unlockAchievement('easter_egg_hunter'); 366 | break; 367 | 368 | case 'all_directories_visited': 369 | await unlockAchievement('explorer'); 370 | break; 371 | } 372 | } 373 | 374 | // Add this function to initialize achievements 375 | export async function initializeAchievements(): Promise { 376 | try { 377 | // Check if achievements file exists, if not create it 378 | if (!fs.existsSync(achievementsPath)) { 379 | await fs.writeFile(achievementsPath, JSON.stringify(achievements, null, 2)); 380 | } 381 | } catch (error) { 382 | console.error('Error initializing achievements:', error); 383 | } 384 | } -------------------------------------------------------------------------------- /src/core/gameInit.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | // Get the directory name of the current module 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 7 | 8 | // Game directories 9 | const SAVE_DIR = path.join(__dirname, '../../saves'); 10 | const LEADERBOARD_PATH = path.join(__dirname, '../../leaderboard.json'); 11 | 12 | export async function initializeGame() { 13 | // Ensure save directory exists 14 | try { 15 | await fs.mkdir(SAVE_DIR, { recursive: true }); 16 | console.log('Save directory initialized.'); 17 | } catch (error) { 18 | console.error('Failed to create save directory:', error); 19 | } 20 | 21 | // Ensure leaderboard file exists 22 | try { 23 | try { 24 | await fs.access(LEADERBOARD_PATH); 25 | } catch { 26 | // Create empty leaderboard if it doesn't exist 27 | await fs.writeFile(LEADERBOARD_PATH, JSON.stringify({ players: [] }, null, 2)); 28 | } 29 | console.log('Leaderboard initialized.'); 30 | } catch (error) { 31 | console.error('Failed to initialize leaderboard:', error); 32 | } 33 | } 34 | 35 | // Helper functions for save management 36 | export async function listSaves(): Promise { 37 | try { 38 | await ensureSavesDir(); 39 | const files = await fs.readdir(SAVE_DIR); 40 | 41 | // Filter out profile files and remove .json extension 42 | return files 43 | .filter(file => file.endsWith('.json') && !file.endsWith('_profile.json')) 44 | .map(file => file.replace('.json', '')); 45 | } catch (error) { 46 | console.error('Error listing saves:', error); 47 | return []; 48 | } 49 | } 50 | 51 | export function getSavePath(saveName: string): string { 52 | // Remove .json extension if it's already there 53 | const baseName = saveName.endsWith('.json') 54 | ? saveName.slice(0, -5) 55 | : saveName; 56 | 57 | return path.join(SAVE_DIR, `${baseName}.json`); 58 | } 59 | 60 | export const getLeaderboardPath = () => LEADERBOARD_PATH; 61 | 62 | // Ensure saves directory exists 63 | export async function ensureSavesDir(): Promise { 64 | try { 65 | await fs.mkdir(SAVE_DIR, { recursive: true }); 66 | } catch (error) { 67 | console.error('Error creating saves directory:', error); 68 | } 69 | } -------------------------------------------------------------------------------- /src/core/gameState.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { getSavePath } from './gameInit'; 3 | import { createProfile, loadProfile, saveProfile } from './playerProfile'; 4 | import { getCurrentProfile } from './playerProfile'; 5 | 6 | export interface GameState { 7 | playerName: string; 8 | currentLevel: number; 9 | startTime: number; 10 | lastSaveTime: number; 11 | completedLevels: number[]; 12 | inventory: string[]; 13 | levelStates: Record; 14 | } 15 | 16 | let currentGameState: GameState | null = null; 17 | 18 | export function getCurrentGameState(): GameState | null { 19 | return currentGameState; 20 | } 21 | 22 | export function setCurrentGameState(gameState: GameState): void { 23 | currentGameState = gameState; 24 | } 25 | 26 | export function createNewGame(playerName: string): GameState { 27 | const gameState: GameState = { 28 | playerName, 29 | currentLevel: 1, 30 | startTime: Date.now(), 31 | lastSaveTime: Date.now(), 32 | completedLevels: [], 33 | inventory: [], 34 | levelStates: {} 35 | }; 36 | 37 | // Create a new profile for this player 38 | createOrLoadProfile(playerName); 39 | 40 | setCurrentGameState(gameState); 41 | 42 | // Save the initial game state 43 | saveGame(); 44 | 45 | return gameState; 46 | } 47 | 48 | export async function saveGame(): Promise<{ success: boolean, message: string }> { 49 | if (!currentGameState) { 50 | return { 51 | success: false, 52 | message: 'No active game to save' 53 | }; 54 | } 55 | 56 | // Update save time 57 | currentGameState.lastSaveTime = Date.now(); 58 | 59 | // Always use player name as save name 60 | const fileName = currentGameState.playerName; 61 | 62 | try { 63 | await fs.writeFile( 64 | getSavePath(fileName), 65 | JSON.stringify(currentGameState, null, 2) 66 | ); 67 | return { 68 | success: true, 69 | message: `Game saved for ${fileName}` 70 | }; 71 | } catch (error) { 72 | return { 73 | success: false, 74 | message: `Failed to save game: ${error}` 75 | }; 76 | } 77 | } 78 | 79 | export async function loadGame(playerName: string): Promise { 80 | try { 81 | const saveData = await fs.readFile(getSavePath(playerName), 'utf-8'); 82 | currentGameState = JSON.parse(saveData) as GameState; 83 | 84 | // Make sure the profile exists 85 | await createOrLoadProfile(currentGameState.playerName); 86 | 87 | console.log(`Game loaded for ${playerName}`); 88 | return true; 89 | } catch (error) { 90 | console.error('Failed to load game:', error); 91 | return false; 92 | } 93 | } 94 | 95 | // Autosave now just calls regular save 96 | export async function autoSave(): Promise<{ success: boolean, message: string }> { 97 | return saveGame(); 98 | } 99 | 100 | async function createOrLoadProfile(playerName: string): Promise { 101 | const profile = await loadProfile(playerName); 102 | if (!profile) { 103 | await createProfile(playerName); 104 | } 105 | } 106 | 107 | export async function completeCurrentLevel(): Promise { 108 | const gameState = getCurrentGameState(); 109 | if (!gameState) return; 110 | 111 | // Add the level to completed levels in the game state 112 | if (!gameState.completedLevels.includes(gameState.currentLevel)) { 113 | gameState.completedLevels.push(gameState.currentLevel); 114 | } 115 | 116 | // Also update the profile 117 | const profile = await getCurrentProfile(); 118 | if (profile) { 119 | if (!profile.completedLevels.includes(gameState.currentLevel)) { 120 | profile.completedLevels.push(gameState.currentLevel); 121 | await saveProfile(profile); 122 | } 123 | } 124 | 125 | // Move to the next level 126 | gameState.currentLevel++; 127 | 128 | // Save the game 129 | await saveGame(); 130 | } -------------------------------------------------------------------------------- /src/core/gameUI.ts: -------------------------------------------------------------------------------- 1 | // Add a status message system 2 | let statusMessage = ''; 3 | let statusMessageTimeout: NodeJS.Timeout | null = null; 4 | 5 | // Function to set a temporary status message 6 | function setStatusMessage(message: string, duration = 3000): void { 7 | statusMessage = message; 8 | 9 | // Clear any existing timeout 10 | if (statusMessageTimeout) { 11 | clearTimeout(statusMessageTimeout); 12 | } 13 | 14 | // Set a timeout to clear the message 15 | statusMessageTimeout = setTimeout(() => { 16 | statusMessage = ''; 17 | // Redraw the UI if needed 18 | renderPrompt(); 19 | }, duration); 20 | } 21 | 22 | // Function to render the command prompt with status message 23 | function renderPrompt(): void { 24 | // Clear the current line 25 | process.stdout.write('\r\x1b[K'); 26 | 27 | // If there's a status message, show it above the prompt 28 | if (statusMessage) { 29 | console.log(statusMessage); 30 | statusMessage = ''; // Clear it after showing 31 | } 32 | 33 | // Show the prompt 34 | process.stdout.write('> '); 35 | } 36 | 37 | // Update the auto-save function call 38 | const saveResult = await autoSave(); 39 | if (saveResult.success) { 40 | setStatusMessage(saveResult.message); 41 | } -------------------------------------------------------------------------------- /src/core/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { getLeaderboardPath } from './gameInit'; 3 | 4 | interface LeaderboardEntry { 5 | playerName: string; 6 | completionTime: number; // in milliseconds 7 | completionDate: string; 8 | } 9 | 10 | interface Leaderboard { 11 | players: LeaderboardEntry[]; 12 | } 13 | 14 | export async function getLeaderboard(): Promise { 15 | try { 16 | const data = await fs.readFile(getLeaderboardPath(), 'utf-8'); 17 | return JSON.parse(data) as Leaderboard; 18 | } catch (error) { 19 | console.error('Failed to read leaderboard:', error); 20 | return { players: [] }; 21 | } 22 | } 23 | 24 | export async function updateLeaderboard( 25 | playerName: string, 26 | completionTime: number 27 | ): Promise { 28 | try { 29 | const leaderboard = await getLeaderboard(); 30 | 31 | // Add new entry 32 | leaderboard.players.push({ 33 | playerName, 34 | completionTime, 35 | completionDate: new Date().toISOString() 36 | }); 37 | 38 | // Sort by completion time (fastest first) 39 | leaderboard.players.sort((a, b) => a.completionTime - b.completionTime); 40 | 41 | // Save updated leaderboard 42 | await fs.writeFile( 43 | getLeaderboardPath(), 44 | JSON.stringify(leaderboard, null, 2) 45 | ); 46 | 47 | return true; 48 | } catch (error) { 49 | console.error('Failed to update leaderboard:', error); 50 | return false; 51 | } 52 | } 53 | 54 | export function formatTime(ms: number): string { 55 | const seconds = Math.floor(ms / 1000); 56 | const minutes = Math.floor(seconds / 60); 57 | const hours = Math.floor(minutes / 60); 58 | 59 | const remainingMinutes = minutes % 60; 60 | const remainingSeconds = seconds % 60; 61 | 62 | return `${hours}h ${remainingMinutes}m ${remainingSeconds}s`; 63 | } -------------------------------------------------------------------------------- /src/core/levelSystem.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentGameState, autoSave } from './gameState'; 2 | import { updateLeaderboard } from './leaderboard'; 3 | 4 | export interface Level { 5 | id: number; 6 | name: string; 7 | description: string; 8 | initialize: () => Promise; 9 | render: () => Promise; 10 | handleInput: (input: string) => Promise; 11 | hints: string[]; 12 | } 13 | 14 | export interface LevelResult { 15 | completed: boolean; 16 | message?: string; 17 | nextAction?: 'next_level' | 'main_menu' | 'continue'; 18 | } 19 | 20 | // Registry of all available levels 21 | const levels: Record = {}; 22 | 23 | export function registerLevel(level: Level) { 24 | levels[level.id] = level; 25 | console.log(`Registered level: ${level.id} - ${level.name}`); 26 | } 27 | 28 | export function getLevelById(id: number): Level | undefined { 29 | return levels[id]; 30 | } 31 | 32 | export function getAllLevels(): Level[] { 33 | return Object.values(levels).sort((a, b) => a.id - b.id); 34 | } 35 | 36 | export async function startLevel(levelId: number): Promise { 37 | const gameState = getCurrentGameState(); 38 | if (!gameState) { 39 | console.error('No active game'); 40 | return false; 41 | } 42 | 43 | const level = getLevelById(levelId); 44 | if (!level) { 45 | console.error(`Level ${levelId} not found`); 46 | return false; 47 | } 48 | 49 | gameState.currentLevel = levelId; 50 | 51 | // Initialize level 52 | await level.initialize(); 53 | 54 | // Auto-save when starting a new level 55 | await autoSave(); 56 | 57 | return true; 58 | } 59 | 60 | export async function completeCurrentLevel(): Promise { 61 | const gameState = getCurrentGameState(); 62 | if (!gameState) return; 63 | 64 | if (!gameState.completedLevels.includes(gameState.currentLevel)) { 65 | gameState.completedLevels.push(gameState.currentLevel); 66 | } 67 | 68 | // If this was the final level, update the leaderboard 69 | const allLevels = getAllLevels(); 70 | if (gameState.completedLevels.length === allLevels.length) { 71 | const totalTime = Date.now() - gameState.startTime; 72 | await updateLeaderboard(gameState.playerName, totalTime); 73 | } 74 | 75 | // Auto-save on level completion 76 | await autoSave(); 77 | } -------------------------------------------------------------------------------- /src/core/playerProfile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import { Achievement } from './achievements'; 4 | import { getCurrentGameState } from './gameState'; 5 | import { getSavePath } from './gameInit'; 6 | 7 | export interface PlayerProfile { 8 | playerName: string; 9 | achievements: Achievement[]; 10 | lastPlayed: number; 11 | totalPlayTime: number; 12 | completedLevels: number[]; 13 | } 14 | 15 | // Use the same directory for profiles and saves 16 | const profilesDir = path.join(process.cwd(), 'saves'); 17 | 18 | // Ensure profiles directory exists 19 | export async function ensureProfilesDir(): Promise { 20 | try { 21 | await fs.mkdir(profilesDir, { recursive: true }); 22 | } catch (error) { 23 | console.error('Error creating profiles directory:', error); 24 | } 25 | } 26 | 27 | // Get profile path for a player 28 | function getProfilePath(playerName: string): string { 29 | return path.join(profilesDir, `${playerName.toLowerCase()}_profile.json`); 30 | } 31 | 32 | // Create a new player profile 33 | export async function createProfile(playerName: string): Promise { 34 | const profile: PlayerProfile = { 35 | playerName, 36 | achievements: [], 37 | lastPlayed: Date.now(), 38 | totalPlayTime: 0, 39 | completedLevels: [] 40 | }; 41 | 42 | await saveProfile(profile); 43 | return profile; 44 | } 45 | 46 | // Load a player profile 47 | export async function loadProfile(playerName: string): Promise { 48 | try { 49 | const profilePath = getProfilePath(playerName); 50 | const data = await fs.readFile(profilePath, 'utf-8'); 51 | return JSON.parse(data) as PlayerProfile; 52 | } catch (error) { 53 | // Profile doesn't exist or can't be read 54 | return null; 55 | } 56 | } 57 | 58 | // Save a player profile 59 | export async function saveProfile(profile: PlayerProfile): Promise { 60 | try { 61 | const profilePath = getProfilePath(profile.playerName); 62 | await fs.writeFile(profilePath, JSON.stringify(profile, null, 2)); 63 | } catch (error) { 64 | console.error('Error saving profile:', error); 65 | } 66 | } 67 | 68 | // Get current player profile 69 | export async function getCurrentProfile(): Promise { 70 | const gameState = getCurrentGameState(); 71 | if (!gameState) return null; 72 | 73 | const profile = await loadProfile(gameState.playerName); 74 | return profile; 75 | } 76 | 77 | // List all profiles 78 | export async function listProfiles(): Promise { 79 | try { 80 | const files = await fs.readdir(profilesDir); 81 | return files 82 | .filter(file => file.endsWith('_profile.json')) 83 | .map(file => file.replace('_profile.json', '')); 84 | } catch (error) { 85 | console.error('Error listing profiles:', error); 86 | return []; 87 | } 88 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | 3 | import { renderEntryMenu } from './ui/entryMenu'; 4 | import { initializeGame } from './core/gameInit'; 5 | import { registerAllLevels } from './levels'; 6 | import { initializeAchievements } from './core/achievements'; 7 | 8 | async function main() { 9 | // Initialize game systems 10 | await initializeGame(); 11 | 12 | // Initialize achievements 13 | await initializeAchievements(); 14 | 15 | // Register all game levels 16 | registerAllLevels(); 17 | 18 | // Render the entry menu to start 19 | await renderEntryMenu(); 20 | } 21 | 22 | main().catch(error => { 23 | console.error('Fatal error:', error); 24 | process.exit(1); 25 | }); -------------------------------------------------------------------------------- /src/levels/index.ts: -------------------------------------------------------------------------------- 1 | import { registerLevel1 } from './level1'; 2 | import { registerLevel2 } from './level2'; 3 | import { registerLevel3 } from './level3'; 4 | import { registerLevel4 } from './level4'; 5 | import { registerLevel5 } from './level5'; 6 | 7 | export function registerAllLevels() { 8 | registerLevel1(); 9 | registerLevel2(); 10 | registerLevel3(); 11 | registerLevel4(); 12 | registerLevel5(); 13 | 14 | console.log('All levels registered successfully.'); 15 | } -------------------------------------------------------------------------------- /src/levels/level1.ts: -------------------------------------------------------------------------------- 1 | import { Level, LevelResult, registerLevel } from '../core/levelSystem'; 2 | import { getCurrentGameState } from '../core/gameState'; 3 | 4 | const level: Level = { 5 | id: 1, 6 | name: 'The Locked Terminal', 7 | description: 'You find yourself in front of a locked terminal. You need to find the password to proceed.', 8 | 9 | async initialize() { 10 | const gameState = getCurrentGameState(); 11 | if (!gameState) return; 12 | 13 | // Initialize level state if not already present 14 | if (!gameState.levelStates[this.id]) { 15 | gameState.levelStates[this.id] = { 16 | attempts: 0, 17 | foundClue1: false, 18 | foundClue2: false 19 | }; 20 | } 21 | }, 22 | 23 | async render() { 24 | const gameState = getCurrentGameState(); 25 | if (!gameState) return; 26 | 27 | const levelState = gameState.levelStates[this.id]; 28 | 29 | console.log('You find yourself in a dimly lit room with a computer terminal.'); 30 | console.log('The screen shows a password prompt, and you need to get in.'); 31 | console.log(''); 32 | console.log('The terminal reads:'); 33 | console.log(''); 34 | console.log(' ╔════════════════════════════════════╗'); 35 | console.log(' ║ SYSTEM LOCKED ║'); 36 | console.log(' ║ ║'); 37 | console.log(' ║ Enter password: ║'); 38 | console.log(' ║ Hint: The admin loves penguins ║'); 39 | console.log(' ╚════════════════════════════════════╝'); 40 | console.log(''); 41 | 42 | if (levelState.foundClue1) { 43 | console.log('You found a sticky note that says: "The password is the mascot\'s name"'); 44 | } 45 | 46 | if (levelState.foundClue2) { 47 | console.log('You found a book about Linux with a page bookmarked about Tux.'); 48 | } 49 | 50 | console.log(''); 51 | console.log('Commands: "look around", "check desk", "check drawer", "enter [password]"'); 52 | }, 53 | 54 | async handleInput(input: string): Promise { 55 | const gameState = getCurrentGameState(); 56 | if (!gameState) { 57 | return { completed: false }; 58 | } 59 | 60 | const levelState = gameState.levelStates[this.id]; 61 | const command = input.toLowerCase().trim(); 62 | 63 | if (command === 'look around') { 64 | return { 65 | completed: false, 66 | message: 'You see a desk with a computer on it. There\'s a drawer in the desk and some books on a shelf.' 67 | }; 68 | } 69 | 70 | if (command === 'check desk') { 71 | levelState.foundClue1 = true; 72 | return { 73 | completed: false, 74 | message: 'You found a sticky note that says: "The password is the mascot\'s name"' 75 | }; 76 | } 77 | 78 | if (command === 'check drawer') { 79 | levelState.foundClue2 = true; 80 | return { 81 | completed: false, 82 | message: 'You found a book about Linux with a page bookmarked about Tux.' 83 | }; 84 | } 85 | 86 | if (command.startsWith('enter ')) { 87 | const password = command.substring(6).trim().toLowerCase(); 88 | levelState.attempts++; 89 | 90 | if (password === 'tux') { 91 | return { 92 | completed: true, 93 | message: 'Access granted! The terminal unlocks, revealing the next challenge.', 94 | nextAction: 'next_level' 95 | }; 96 | } else { 97 | return { 98 | completed: false, 99 | message: `Incorrect password. The system rejects your attempt. (Attempt ${levelState.attempts})` 100 | }; 101 | } 102 | } 103 | 104 | return { 105 | completed: false, 106 | message: 'Unknown command. Try something else.' 107 | }; 108 | }, 109 | 110 | hints: [ 111 | 'Try looking around the room for clues.', 112 | 'The password is related to Linux.', 113 | 'Tux is the Linux mascot - a penguin.' 114 | ] 115 | }; 116 | 117 | export function registerLevel1() { 118 | registerLevel(level); 119 | } -------------------------------------------------------------------------------- /src/levels/level2.ts: -------------------------------------------------------------------------------- 1 | import { Level, LevelResult, registerLevel } from "../core/levelSystem"; 2 | import { getCurrentGameState } from "../core/gameState"; 3 | 4 | interface Level2State { 5 | currentDir: string; 6 | fileSystem: { 7 | [key: string]: { 8 | type: "dir" | "file"; 9 | contents?: string[]; 10 | content?: string; 11 | }; 12 | }; 13 | foundKey?: boolean; 14 | } 15 | 16 | const level: Level = { 17 | id: 2, 18 | name: "File System Maze", 19 | description: "Navigate through a virtual file system to find the key.", 20 | 21 | async initialize() { 22 | const gameState = getCurrentGameState(); 23 | if (!gameState) return; 24 | 25 | // Initialize level state if not already present 26 | //if (!gameState.levelStates[this.id]) { 27 | gameState.levelStates[this.id] = { 28 | currentDir: "/home/user", 29 | fileSystem: { 30 | "/home/user": { 31 | type: "dir", 32 | contents: ["Documents", "Pictures", ".hidden"], 33 | }, 34 | "/home/user/Documents": { 35 | type: "dir", 36 | contents: ["notes.txt", "system.key"], 37 | }, 38 | "/home/user/Documents/notes.txt": { 39 | type: "file", 40 | content: "The system key is hidden somewhere in this directory...", 41 | }, 42 | "/home/user/Documents/system.key": { 43 | type: "file", 44 | content: "Congratulations! You found the system key: XK42-9Y7Z", 45 | }, 46 | "/home/user/Pictures": { 47 | type: "dir", 48 | contents: ["vacation.jpg"], 49 | }, 50 | "/home/user/Pictures/vacation.jpg": { 51 | type: "file", 52 | content: "Just a nice beach photo.", 53 | }, 54 | "/home/user/.hidden": { 55 | type: "dir", 56 | contents: ["readme.md"], 57 | }, 58 | "/home/user/.hidden/readme.md": { 59 | type: "file", 60 | content: "Nothing to see here...", 61 | }, 62 | }, 63 | }; 64 | //} 65 | }, 66 | 67 | async render() { 68 | const gameState = getCurrentGameState(); 69 | if (!gameState) return; 70 | 71 | // Make sure level state is initialized 72 | if (!gameState.levelStates[this.id]) { 73 | await this.initialize(); 74 | return; // Return here to ensure the state is available in the next render 75 | } 76 | 77 | const levelState = gameState.levelStates[this.id] as Level2State; 78 | if (!levelState || !levelState.currentDir || !levelState.fileSystem) { 79 | console.log("[DEBUG] Level state not properly initialized"); 80 | console.log(JSON.stringify(levelState, null, 2)); 81 | console.log("Error: Level state not properly initialized"); 82 | return; 83 | } 84 | 85 | const { currentDir, fileSystem } = levelState; 86 | 87 | console.log( 88 | "You're in a virtual file system and need to find the system key." 89 | ); 90 | console.log(""); 91 | console.log(`Current directory: ${currentDir}`); 92 | console.log(""); 93 | 94 | if (fileSystem[currentDir].type === "dir") { 95 | console.log("Contents:"); 96 | if (fileSystem[currentDir].contents.length === 0) { 97 | console.log(" (empty directory)"); 98 | } else { 99 | fileSystem[currentDir].contents.forEach((item) => { 100 | const path = `${currentDir}/${item}`; 101 | const type = fileSystem[path].type === "dir" ? "Directory" : "File"; 102 | console.log(` ${item} (${type})`); 103 | }); 104 | } 105 | } else { 106 | console.log("File content:"); 107 | console.log(fileSystem[currentDir].content); 108 | } 109 | 110 | console.log(""); 111 | console.log( 112 | 'Commands: "ls", "cd [dir]", "cat [file]", "pwd", "find [name]"' 113 | ); 114 | }, 115 | 116 | async handleInput(input: string): Promise { 117 | const gameState = getCurrentGameState(); 118 | if (!gameState) { 119 | return { completed: false }; 120 | } 121 | 122 | // Make sure level state is initialized 123 | if (!gameState.levelStates[this.id]) { 124 | await this.initialize(); 125 | } 126 | 127 | const levelState = gameState.levelStates[this.id]; 128 | const fileSystem = levelState.fileSystem; 129 | const command = input.trim(); 130 | 131 | // Split command into parts 132 | const parts = command.split(" "); 133 | const cmd = parts[0].toLowerCase(); 134 | 135 | if (cmd === "ls") { 136 | // List directory contents 137 | return { 138 | completed: false, 139 | message: fileSystem[levelState.currentDir].contents.join("\n"), 140 | }; 141 | } 142 | 143 | if (cmd === "pwd") { 144 | // Print working directory 145 | return { 146 | completed: false, 147 | message: levelState.currentDir, 148 | }; 149 | } 150 | 151 | if (cmd === "cd" && parts.length > 1) { 152 | // Change directory 153 | const target = parts[1]; 154 | 155 | if (target === "..") { 156 | // Go up one directory 157 | const pathParts = levelState.currentDir.split("/"); 158 | if (pathParts.length > 2) { 159 | // Don't go above /home/user 160 | pathParts.pop(); 161 | levelState.currentDir = pathParts.join("/"); 162 | return { 163 | completed: false, 164 | message: `Changed directory to ${levelState.currentDir}`, 165 | }; 166 | } else { 167 | return { 168 | completed: false, 169 | message: "Cannot go above the home directory.", 170 | }; 171 | } 172 | } else if (target === ".") { 173 | // Stay in current directory 174 | return { 175 | completed: false, 176 | message: `Still in ${levelState.currentDir}`, 177 | }; 178 | } else { 179 | // Go to specified directory 180 | const newPath = `${levelState.currentDir}/${target}`; 181 | 182 | if (fileSystem[newPath] && fileSystem[newPath].type === "dir") { 183 | levelState.currentDir = newPath; 184 | return { 185 | completed: false, 186 | message: `Changed directory to ${levelState.currentDir}`, 187 | }; 188 | } else { 189 | return { 190 | completed: false, 191 | message: `Cannot change to ${target}: No such directory`, 192 | }; 193 | } 194 | } 195 | } 196 | 197 | if (cmd === "cat" && parts.length > 1) { 198 | // View file contents 199 | const target = parts[1]; 200 | const filePath = `${levelState.currentDir}/${target}`; 201 | 202 | if (fileSystem[filePath] && fileSystem[filePath].type === "file") { 203 | const content = fileSystem[filePath].content; 204 | 205 | // Check if this is the key file 206 | if (filePath === "/home/user/Documents/system.key") { 207 | levelState.foundKey = true; 208 | return { 209 | completed: true, 210 | message: `You found the system key! The file contains: ${content}`, 211 | nextAction: "next_level", 212 | }; 213 | } 214 | 215 | return { 216 | completed: false, 217 | message: `File contents: ${content}`, 218 | }; 219 | } else { 220 | return { 221 | completed: false, 222 | message: `Cannot read ${target}: No such file`, 223 | }; 224 | } 225 | } 226 | 227 | if (cmd === "find" && parts.length > 1) { 228 | // Simple find implementation 229 | const target = parts[1]; 230 | const results: string[] = []; 231 | 232 | // Search through the file system 233 | Object.keys(fileSystem).forEach((path) => { 234 | if (path.includes(target)) { 235 | results.push(path); 236 | } 237 | }); 238 | 239 | if (results.length > 0) { 240 | return { 241 | completed: false, 242 | message: `Found matches:\n${results.join("\n")}`, 243 | }; 244 | } else { 245 | return { 246 | completed: false, 247 | message: `No matches found for "${target}"`, 248 | }; 249 | } 250 | } 251 | 252 | return { 253 | completed: false, 254 | message: "Unknown command or invalid syntax.", 255 | }; 256 | }, 257 | 258 | hints: [ 259 | 'Try using basic Linux commands like "ls", "cd", and "cat".', 260 | "Remember that hidden files and directories start with a dot (.)", 261 | 'Use "ls" to list files, "cd" to change directories, and "cat" to view file contents.', 262 | ], 263 | }; 264 | 265 | export function registerLevel2() { 266 | registerLevel(level); 267 | } 268 | -------------------------------------------------------------------------------- /src/levels/level3.ts: -------------------------------------------------------------------------------- 1 | import { Level, LevelResult, registerLevel } from '../core/levelSystem'; 2 | import { getCurrentGameState } from '../core/gameState'; 3 | 4 | const level: Level = { 5 | id: 3, 6 | name: 'Process Control', 7 | description: 'Manage system processes to unlock the next level.', 8 | 9 | async initialize() { 10 | const gameState = getCurrentGameState(); 11 | if (!gameState) return; 12 | 13 | // Initialize level state if not already present 14 | if (!gameState.levelStates[this.id]) { 15 | gameState.levelStates[this.id] = { 16 | processes: [ 17 | { pid: 1, name: 'systemd', cpu: 0.1, memory: 4.2, status: 'running' }, 18 | { pid: 423, name: 'sshd', cpu: 0.0, memory: 1.1, status: 'running' }, 19 | { pid: 587, name: 'nginx', cpu: 0.2, memory: 2.3, status: 'running' }, 20 | { pid: 842, name: 'malware.bin', cpu: 99.7, memory: 85.5, status: 'running' }, 21 | { pid: 967, name: 'bash', cpu: 0.0, memory: 0.5, status: 'running' }, 22 | { pid: 1024, name: 'firewall', cpu: 0.1, memory: 1.8, status: 'stopped' } 23 | ], 24 | firewallStarted: false, 25 | malwareKilled: false 26 | }; 27 | } 28 | }, 29 | 30 | async render() { 31 | const gameState = getCurrentGameState(); 32 | if (!gameState) return; 33 | 34 | // Make sure level state is initialized 35 | if (!gameState.levelStates[this.id]) { 36 | await this.initialize(); 37 | } 38 | 39 | const levelState = gameState.levelStates[this.id]; 40 | 41 | console.log('You\'ve gained access to the system\'s process manager.'); 42 | console.log('Something seems to be consuming a lot of resources.'); 43 | console.log('You need to stop the malicious process and start the firewall.'); 44 | console.log(''); 45 | 46 | console.log('Current processes:'); 47 | console.log('PID NAME CPU% MEM% STATUS'); 48 | console.log('--------------------------------------------'); 49 | 50 | levelState.processes.forEach(proc => { 51 | console.log( 52 | `${proc.pid.toString().padEnd(7)}${proc.name.padEnd(13)}${proc.cpu.toFixed(1).padEnd(8)}${proc.memory.toFixed(1).padEnd(8)}${proc.status}` 53 | ); 54 | }); 55 | 56 | console.log(''); 57 | console.log('System status: ' + (levelState.malwareKilled && levelState.firewallStarted ? 58 | 'SECURE' : 'VULNERABLE')); 59 | console.log(''); 60 | console.log('Commands: "ps", "kill [pid]", "start [pid]", "info [pid]"'); 61 | }, 62 | 63 | async handleInput(input: string): Promise { 64 | const gameState = getCurrentGameState(); 65 | if (!gameState) { 66 | return { completed: false }; 67 | } 68 | 69 | // Make sure level state is initialized 70 | if (!gameState.levelStates[this.id]) { 71 | await this.initialize(); 72 | } 73 | 74 | const levelState = gameState.levelStates[this.id]; 75 | const command = input.trim(); 76 | 77 | // Split command into parts 78 | const parts = command.split(' '); 79 | const cmd = parts[0].toLowerCase(); 80 | 81 | if (cmd === 'ps') { 82 | // Just show processes again (same as render) 83 | return { 84 | completed: false, 85 | message: 'Process list displayed.' 86 | }; 87 | } 88 | 89 | if (cmd === 'kill' && parts.length > 1) { 90 | const pid = parseInt(parts[1]); 91 | const process = levelState.processes.find(p => p.pid === pid); 92 | 93 | if (!process) { 94 | return { 95 | completed: false, 96 | message: `No process with PID ${pid} found.` 97 | }; 98 | } 99 | 100 | if (process.status === 'stopped') { 101 | return { 102 | completed: false, 103 | message: `Process ${pid} (${process.name}) is already stopped.` 104 | }; 105 | } 106 | 107 | // Stop the process 108 | process.status = 'stopped'; 109 | 110 | // Check if it was the malware 111 | if (process.name === 'malware.bin') { 112 | levelState.malwareKilled = true; 113 | 114 | // Check if level is completed 115 | if (levelState.firewallStarted) { 116 | return { 117 | completed: true, 118 | message: 'System secured! Malware stopped and firewall running.', 119 | nextAction: 'next_level' 120 | }; 121 | } 122 | 123 | return { 124 | completed: false, 125 | message: `Killed malicious process ${pid} (${process.name}). Now start the firewall!` 126 | }; 127 | } 128 | 129 | return { 130 | completed: false, 131 | message: `Process ${pid} (${process.name}) stopped.` 132 | }; 133 | } 134 | 135 | if (cmd === 'start' && parts.length > 1) { 136 | const pid = parseInt(parts[1]); 137 | const process = levelState.processes.find(p => p.pid === pid); 138 | 139 | if (!process) { 140 | return { 141 | completed: false, 142 | message: `No process with PID ${pid} found.` 143 | }; 144 | } 145 | 146 | if (process.status === 'running') { 147 | return { 148 | completed: false, 149 | message: `Process ${pid} (${process.name}) is already running.` 150 | }; 151 | } 152 | 153 | // Start the process 154 | process.status = 'running'; 155 | 156 | // Check if it was the firewall 157 | if (process.name === 'firewall') { 158 | levelState.firewallStarted = true; 159 | 160 | // Check if level is completed 161 | if (levelState.malwareKilled) { 162 | return { 163 | completed: true, 164 | message: 'System secured! Malware stopped and firewall running.', 165 | nextAction: 'next_level' 166 | }; 167 | } 168 | 169 | return { 170 | completed: false, 171 | message: `Started firewall process ${pid}. Now kill the malware!` 172 | }; 173 | } 174 | 175 | return { 176 | completed: false, 177 | message: `Process ${pid} (${process.name}) started.` 178 | }; 179 | } 180 | 181 | if (cmd === 'info' && parts.length > 1) { 182 | const pid = parseInt(parts[1]); 183 | const process = levelState.processes.find(p => p.pid === pid); 184 | 185 | if (!process) { 186 | return { 187 | completed: false, 188 | message: `No process with PID ${pid} found.` 189 | }; 190 | } 191 | 192 | let info = `Process Information:\n`; 193 | info += `PID: ${process.pid}\n`; 194 | info += `Name: ${process.name}\n`; 195 | info += `CPU Usage: ${process.cpu.toFixed(1)}%\n`; 196 | info += `Memory Usage: ${process.memory.toFixed(1)}%\n`; 197 | info += `Status: ${process.status}\n`; 198 | 199 | if (process.name === 'malware.bin') { 200 | info += `\nWARNING: This process appears to be malicious!`; 201 | } else if (process.name === 'firewall') { 202 | info += `\nNOTE: This is the system's security service.`; 203 | } 204 | 205 | return { 206 | completed: false, 207 | message: info 208 | }; 209 | } 210 | 211 | return { 212 | completed: false, 213 | message: 'Unknown command or invalid syntax.' 214 | }; 215 | }, 216 | 217 | hints: [ 218 | 'Use "ps" to list all processes and their PIDs.', 219 | 'Look for processes with unusually high CPU or memory usage.', 220 | 'Use "kill [pid]" to stop a process and "start [pid]" to start one.', 221 | 'You need to both kill the malware and start the firewall to complete the level.' 222 | ] 223 | }; 224 | 225 | export function registerLevel3() { 226 | registerLevel(level); 227 | } -------------------------------------------------------------------------------- /src/levels/level4.ts: -------------------------------------------------------------------------------- 1 | import { Level, LevelResult, registerLevel } from '../core/levelSystem'; 2 | import { getCurrentGameState } from '../core/gameState'; 3 | 4 | const level: Level = { 5 | id: 4, 6 | name: 'Permissions Puzzle', 7 | description: 'Fix file permissions to access a protected file.', 8 | 9 | async initialize() { 10 | const gameState = getCurrentGameState(); 11 | if (!gameState) return; 12 | 13 | // Initialize level state if not already present 14 | if (!gameState.levelStates[this.id]) { 15 | gameState.levelStates[this.id] = { 16 | files: [ 17 | { name: 'README.txt', permissions: 'rw-r--r--', owner: 'user', group: 'user' }, 18 | { name: 'secret_data.db', permissions: '----------', owner: 'root', group: 'root' }, 19 | { name: 'change_permissions.sh', permissions: 'r--------', owner: 'user', group: 'user' }, 20 | { name: 'access_key.bin', permissions: 'rw-------', owner: 'root', group: 'user' } 21 | ], 22 | currentUser: 'user', 23 | sudoAvailable: false, 24 | scriptExecutable: false, 25 | accessKeyReadable: false 26 | }; 27 | } 28 | }, 29 | 30 | async render() { 31 | const gameState = getCurrentGameState(); 32 | if (!gameState) return; 33 | 34 | // Make sure level state is initialized 35 | if (!gameState.levelStates[this.id]) { 36 | await this.initialize(); 37 | } 38 | 39 | const levelState = gameState.levelStates[this.id]; 40 | 41 | console.log('You need to access the protected files to proceed.'); 42 | console.log(`Current user: ${levelState.currentUser}`); 43 | console.log(''); 44 | 45 | console.log('Files in current directory:'); 46 | console.log('PERMISSIONS OWNER GROUP FILENAME'); 47 | console.log('----------------------------------------'); 48 | 49 | levelState.files.forEach(file => { 50 | console.log( 51 | `${file.permissions} ${file.owner.padEnd(6)}${file.group.padEnd(7)}${file.name}` 52 | ); 53 | }); 54 | 55 | console.log(''); 56 | console.log('Commands: "ls", "cat [file]", "chmod [permissions] [file]", "sudo [command]", "sh [script]"'); 57 | }, 58 | 59 | async handleInput(input: string): Promise { 60 | const gameState = getCurrentGameState(); 61 | if (!gameState) { 62 | return { completed: false }; 63 | } 64 | 65 | // Make sure level state is initialized 66 | if (!gameState.levelStates[this.id]) { 67 | await this.initialize(); 68 | } 69 | 70 | const levelState = gameState.levelStates[this.id]; 71 | const command = input.trim(); 72 | 73 | // Split command into parts 74 | const parts = command.split(' '); 75 | const cmd = parts[0].toLowerCase(); 76 | 77 | if (cmd === 'ls') { 78 | // Just show files again (same as render) 79 | return { 80 | completed: false, 81 | message: 'File list displayed.' 82 | }; 83 | } 84 | 85 | if (cmd === 'cat' && parts.length > 1) { 86 | const fileName = parts[1]; 87 | const file = levelState.files.find(f => f.name === fileName); 88 | 89 | if (!file) { 90 | return { 91 | completed: false, 92 | message: `File ${fileName} not found.` 93 | }; 94 | } 95 | 96 | // Check if user has read permission 97 | const canRead = (levelState.currentUser === file.owner && file.permissions[0] === 'r') || 98 | (levelState.currentUser !== file.owner && 99 | levelState.currentUser === file.group && file.permissions[3] === 'r') || 100 | (levelState.currentUser !== file.owner && 101 | levelState.currentUser !== file.group && file.permissions[6] === 'r') || 102 | (levelState.currentUser === 'root'); // root can read anything 103 | 104 | if (!canRead) { 105 | return { 106 | completed: false, 107 | message: `Permission denied: Cannot read ${fileName}` 108 | }; 109 | } 110 | 111 | // Return file contents based on filename 112 | if (fileName === 'README.txt') { 113 | return { 114 | completed: false, 115 | message: `File contents:\n\nWelcome to the permissions puzzle!\n\nYou need to:\n1. Make the script executable\n2. Run the script to gain sudo access\n3. Access the protected data` 116 | }; 117 | } else if (fileName === 'secret_data.db') { 118 | return { 119 | completed: true, 120 | message: `File contents:\n\nCONGRATULATIONS!\nYou've successfully navigated the permissions puzzle and accessed the protected data.\n\nProceeding to next level...`, 121 | nextAction: 'next_level' 122 | }; 123 | } else if (fileName === 'change_permissions.sh') { 124 | return { 125 | completed: false, 126 | message: `File contents:\n\n#!/bin/bash\n# This script grants sudo access\necho "Granting temporary sudo access..."\n# More script content here...` 127 | }; 128 | } else if (fileName === 'access_key.bin') { 129 | levelState.accessKeyReadable = true; 130 | return { 131 | completed: false, 132 | message: `File contents:\n\nBINARY DATA: sudo_access_granted=true\n\nYou can now use sudo commands!` 133 | }; 134 | } 135 | } 136 | 137 | if (cmd === 'chmod' && parts.length > 2) { 138 | const permissions = parts[1]; 139 | const fileName = parts[2]; 140 | const file = levelState.files.find(f => f.name === fileName); 141 | 142 | if (!file) { 143 | return { 144 | completed: false, 145 | message: `File ${fileName} not found.` 146 | }; 147 | } 148 | 149 | // Check if user has permission to change permissions 150 | const canModify = (levelState.currentUser === file.owner) || 151 | (levelState.currentUser === 'root'); 152 | 153 | if (!canModify) { 154 | return { 155 | completed: false, 156 | message: `Permission denied: Cannot modify permissions of ${fileName}` 157 | }; 158 | } 159 | 160 | // Simple permission handling (just for the game) 161 | if (permissions === '+x' || permissions === 'u+x') { 162 | // Make executable for owner 163 | const newPerms = file.permissions.split(''); 164 | newPerms[2] = 'x'; 165 | file.permissions = newPerms.join(''); 166 | 167 | if (fileName === 'change_permissions.sh') { 168 | levelState.scriptExecutable = true; 169 | } 170 | 171 | return { 172 | completed: false, 173 | message: `Changed permissions of ${fileName} to ${file.permissions}` 174 | }; 175 | } else if (permissions === '+r' || permissions === 'g+r') { 176 | // Make readable for group 177 | const newPerms = file.permissions.split(''); 178 | newPerms[3] = 'r'; 179 | file.permissions = newPerms.join(''); 180 | 181 | return { 182 | completed: false, 183 | message: `Changed permissions of ${fileName} to ${file.permissions}` 184 | }; 185 | } else { 186 | // For simplicity, just update the permissions string 187 | file.permissions = permissions.length === 10 ? permissions : file.permissions; 188 | return { 189 | completed: false, 190 | message: `Changed permissions of ${fileName} to ${file.permissions}` 191 | }; 192 | } 193 | } 194 | 195 | if (cmd === 'sh' && parts.length > 1) { 196 | const scriptName = parts[1]; 197 | const file = levelState.files.find(f => f.name === scriptName); 198 | 199 | if (!file) { 200 | return { 201 | completed: false, 202 | message: `Script ${scriptName} not found.` 203 | }; 204 | } 205 | 206 | // Check if script is executable 207 | const canExecute = file.permissions[2] === 'x'; 208 | 209 | if (!canExecute) { 210 | return { 211 | completed: false, 212 | message: `Permission denied: Cannot execute ${scriptName}. Make it executable first.` 213 | }; 214 | } 215 | 216 | if (scriptName === 'change_permissions.sh') { 217 | levelState.sudoAvailable = true; 218 | return { 219 | completed: false, 220 | message: `Executing ${scriptName}...\n\nGranting temporary sudo access...\nYou can now use sudo commands!` 221 | }; 222 | } 223 | 224 | return { 225 | completed: false, 226 | message: `Executed ${scriptName}, but nothing happened.` 227 | }; 228 | } 229 | 230 | if (cmd === 'sudo' && parts.length > 1) { 231 | if (!levelState.sudoAvailable && !levelState.accessKeyReadable) { 232 | return { 233 | completed: false, 234 | message: `sudo: command not found. You need to gain sudo access first.` 235 | }; 236 | } 237 | 238 | // Handle sudo commands 239 | const sudoCmd = parts[1].toLowerCase(); 240 | 241 | if (sudoCmd === 'cat' && parts.length > 2) { 242 | const fileName = parts[2]; 243 | const file = levelState.files.find(f => f.name === fileName); 244 | 245 | if (!file) { 246 | return { 247 | completed: false, 248 | message: `File ${fileName} not found.` 249 | }; 250 | } 251 | 252 | // With sudo, we can read any file 253 | if (fileName === 'secret_data.db') { 254 | return { 255 | completed: true, 256 | message: `File contents:\n\nCONGRATULATIONS!\nYou've successfully navigated the permissions puzzle and accessed the protected data.\n\nProceeding to next level...`, 257 | nextAction: 'next_level' 258 | }; 259 | } else { 260 | return { 261 | completed: false, 262 | message: `File contents of ${fileName} displayed with sudo privileges.` 263 | }; 264 | } 265 | } else if (sudoCmd === 'chmod' && parts.length > 3) { 266 | const permissions = parts[2]; 267 | const fileName = parts[3]; 268 | const file = levelState.files.find(f => f.name === fileName); 269 | 270 | if (!file) { 271 | return { 272 | completed: false, 273 | message: `File ${fileName} not found.` 274 | }; 275 | } 276 | 277 | // With sudo, we can change any permissions 278 | file.permissions = permissions.length === 10 ? permissions : 'rw-r--r--'; 279 | 280 | return { 281 | completed: false, 282 | message: `Changed permissions of ${fileName} to ${file.permissions} with sudo privileges.` 283 | }; 284 | } 285 | } 286 | 287 | return { 288 | completed: false, 289 | message: 'Unknown command or invalid syntax.' 290 | }; 291 | }, 292 | 293 | hints: [ 294 | 'First read the README.txt file to understand what you need to do.', 295 | 'You need to make the script executable with "chmod +x change_permissions.sh"', 296 | 'After making the script executable, run it with "sh change_permissions.sh"', 297 | 'Once you have sudo access, you can access any file with "sudo cat secret_data.db"', 298 | 'Alternatively, you can make the access_key.bin readable by your group with "chmod g+r access_key.bin"' 299 | ] 300 | }; 301 | 302 | export function registerLevel4() { 303 | registerLevel(level); 304 | } -------------------------------------------------------------------------------- /src/levels/level5.ts: -------------------------------------------------------------------------------- 1 | import { Level, LevelResult, registerLevel } from '../core/levelSystem'; 2 | import { getCurrentGameState } from '../core/gameState'; 3 | 4 | const level: Level = { 5 | id: 5, 6 | name: 'Network Escape', 7 | description: 'Configure network settings to escape the isolated system.', 8 | 9 | async initialize() { 10 | const gameState = getCurrentGameState(); 11 | if (!gameState) return; 12 | 13 | // Initialize level state if not already present 14 | if (!gameState.levelStates[this.id]) { 15 | gameState.levelStates[this.id] = { 16 | interfaces: [ 17 | { name: 'lo', status: 'UP', ip: '127.0.0.1', netmask: '255.0.0.0' }, 18 | { name: 'eth0', status: 'DOWN', ip: '', netmask: '' }, 19 | { name: 'wlan0', status: 'DOWN', ip: '', netmask: '' } 20 | ], 21 | firewall: { 22 | enabled: true, 23 | rules: [ 24 | { port: 22, protocol: 'tcp', action: 'DENY' }, 25 | { port: 80, protocol: 'tcp', action: 'DENY' }, 26 | { port: 443, protocol: 'tcp', action: 'DENY' }, 27 | { port: 8080, protocol: 'tcp', action: 'DENY' } 28 | ] 29 | }, 30 | dns: { 31 | configured: false, 32 | server: '' 33 | }, 34 | gateway: { 35 | configured: false, 36 | address: '' 37 | }, 38 | connections: [], 39 | escapePortal: { 40 | host: 'escape.portal', 41 | ip: '10.0.0.1', 42 | port: 8080 43 | } 44 | }; 45 | } 46 | }, 47 | 48 | async render() { 49 | const gameState = getCurrentGameState(); 50 | if (!gameState) return; 51 | 52 | // Make sure level state is initialized 53 | if (!gameState.levelStates[this.id]) { 54 | await this.initialize(); 55 | } 56 | 57 | const levelState = gameState.levelStates[this.id]; 58 | 59 | console.log('You\'re trapped in an isolated system. Configure the network to escape.'); 60 | console.log(''); 61 | 62 | console.log('Network Interfaces:'); 63 | console.log('NAME STATUS IP NETMASK'); 64 | console.log('----------------------------------------'); 65 | 66 | levelState.interfaces.forEach(iface => { 67 | console.log( 68 | `${iface.name.padEnd(7)}${iface.status.padEnd(9)}${iface.ip.padEnd(14)}${iface.netmask}` 69 | ); 70 | }); 71 | 72 | console.log(''); 73 | console.log('Firewall Status: ' + (levelState.firewall.enabled ? 'ENABLED' : 'DISABLED')); 74 | 75 | if (levelState.firewall.enabled) { 76 | console.log('Firewall Rules:'); 77 | levelState.firewall.rules.forEach(rule => { 78 | console.log(` ${rule.action} ${rule.protocol.toUpperCase()} port ${rule.port}`); 79 | }); 80 | } 81 | 82 | console.log(''); 83 | console.log('DNS Server: ' + (levelState.dns.configured ? levelState.dns.server : 'Not configured')); 84 | console.log('Default Gateway: ' + (levelState.gateway.configured ? levelState.gateway.address : 'Not configured')); 85 | 86 | console.log(''); 87 | console.log('Active Connections:'); 88 | if (levelState.connections.length === 0) { 89 | console.log(' None'); 90 | } else { 91 | levelState.connections.forEach(conn => { 92 | console.log(` ${conn.protocol.toUpperCase()} ${conn.localAddress}:${conn.localPort} -> ${conn.remoteAddress}:${conn.remotePort}`); 93 | }); 94 | } 95 | 96 | console.log(''); 97 | console.log('Commands: "ifconfig", "ifup [interface]", "ifconfig [interface] [ip] [netmask]",'); 98 | console.log(' "firewall-cmd --list", "firewall-cmd --disable", "firewall-cmd --allow [port]",'); 99 | console.log(' "route add default [gateway]", "echo nameserver [ip] > /etc/resolv.conf",'); 100 | console.log(' "ping [host]", "nslookup [host]", "connect [host] [port]"'); 101 | }, 102 | 103 | async handleInput(input: string): Promise { 104 | const gameState = getCurrentGameState(); 105 | if (!gameState) { 106 | return { completed: false }; 107 | } 108 | 109 | // Make sure level state is initialized 110 | if (!gameState.levelStates[this.id]) { 111 | await this.initialize(); 112 | } 113 | 114 | const levelState = gameState.levelStates[this.id]; 115 | const command = input.trim(); 116 | 117 | // Split command into parts 118 | const parts = command.split(' '); 119 | const cmd = parts[0].toLowerCase(); 120 | 121 | if (cmd === 'ifconfig') { 122 | if (parts.length === 1) { 123 | // Show all interfaces 124 | return { 125 | completed: false, 126 | message: 'Network interfaces displayed.' 127 | }; 128 | } else if (parts.length >= 4) { 129 | // Configure an interface 130 | const ifaceName = parts[1]; 131 | const ip = parts[2]; 132 | const netmask = parts[3]; 133 | 134 | const iface = levelState.interfaces.find(i => i.name === ifaceName); 135 | 136 | if (!iface) { 137 | return { 138 | completed: false, 139 | message: `Interface ${ifaceName} not found.` 140 | }; 141 | } 142 | 143 | if (iface.status === 'DOWN') { 144 | return { 145 | completed: false, 146 | message: `Interface ${ifaceName} is down. Bring it up first with "ifup ${ifaceName}".` 147 | }; 148 | } 149 | 150 | // Simple IP validation 151 | if (!ip.match(/^\d+\.\d+\.\d+\.\d+$/)) { 152 | return { 153 | completed: false, 154 | message: `Invalid IP address format: ${ip}` 155 | }; 156 | } 157 | 158 | // Simple netmask validation 159 | if (!netmask.match(/^\d+\.\d+\.\d+\.\d+$/)) { 160 | return { 161 | completed: false, 162 | message: `Invalid netmask format: ${netmask}` 163 | }; 164 | } 165 | 166 | // Set IP and netmask 167 | iface.ip = ip; 168 | iface.netmask = netmask; 169 | 170 | return { 171 | completed: false, 172 | message: `Configured ${ifaceName} with IP ${ip} and netmask ${netmask}.` 173 | }; 174 | } 175 | } 176 | 177 | if (cmd === 'ifup' && parts.length > 1) { 178 | const ifaceName = parts[1]; 179 | const iface = levelState.interfaces.find(i => i.name === ifaceName); 180 | 181 | if (!iface) { 182 | return { 183 | completed: false, 184 | message: `Interface ${ifaceName} not found.` 185 | }; 186 | } 187 | 188 | if (iface.status === 'UP') { 189 | return { 190 | completed: false, 191 | message: `Interface ${ifaceName} is already up.` 192 | }; 193 | } 194 | 195 | // Bring interface up 196 | iface.status = 'UP'; 197 | 198 | return { 199 | completed: false, 200 | message: `Interface ${ifaceName} is now UP.` 201 | }; 202 | } 203 | 204 | if (cmd === 'firewall-cmd') { 205 | if (parts.length > 1) { 206 | const subCmd = parts[1]; 207 | 208 | if (subCmd === '--list') { 209 | // List firewall rules 210 | let message = 'Firewall rules:\n'; 211 | levelState.firewall.rules.forEach(rule => { 212 | message += `${rule.action} ${rule.protocol.toUpperCase()} port ${rule.port}\n`; 213 | }); 214 | 215 | return { 216 | completed: false, 217 | message 218 | }; 219 | } else if (subCmd === '--disable') { 220 | // Disable firewall 221 | levelState.firewall.enabled = false; 222 | 223 | return { 224 | completed: false, 225 | message: 'Firewall disabled.' 226 | }; 227 | } else if (subCmd === '--allow' && parts.length > 2) { 228 | // Allow a port 229 | const port = parseInt(parts[2]); 230 | 231 | if (isNaN(port) || port < 1 || port > 65535) { 232 | return { 233 | completed: false, 234 | message: `Invalid port number: ${parts[2]}` 235 | }; 236 | } 237 | 238 | // Find the rule for this port 239 | const ruleIndex = levelState.firewall.rules.findIndex(r => r.port === port); 240 | 241 | if (ruleIndex >= 0) { 242 | // Update existing rule 243 | levelState.firewall.rules[ruleIndex].action = 'ALLOW'; 244 | } else { 245 | // Add new rule 246 | levelState.firewall.rules.push({ 247 | port, 248 | protocol: 'tcp', 249 | action: 'ALLOW' 250 | }); 251 | } 252 | 253 | return { 254 | completed: false, 255 | message: `Allowed TCP port ${port} through firewall.` 256 | }; 257 | } 258 | } 259 | } 260 | 261 | if (cmd === 'route' && parts[1] === 'add' && parts[2] === 'default' && parts.length > 3) { 262 | const gateway = parts[3]; 263 | 264 | // Simple IP validation 265 | if (!gateway.match(/^\d+\.\d+\.\d+\.\d+$/)) { 266 | return { 267 | completed: false, 268 | message: `Invalid gateway address format: ${gateway}` 269 | }; 270 | } 271 | 272 | // Set default gateway 273 | levelState.gateway.configured = true; 274 | levelState.gateway.address = gateway; 275 | 276 | return { 277 | completed: false, 278 | message: `Default gateway set to ${gateway}.` 279 | }; 280 | } 281 | 282 | if (cmd === 'echo' && parts[1] === 'nameserver' && parts.length > 3 && parts[3] === '>' && parts[4] === '/etc/resolv.conf') { 283 | const dnsServer = parts[2]; 284 | 285 | // Simple IP validation 286 | if (!dnsServer.match(/^\d+\.\d+\.\d+\.\d+$/)) { 287 | return { 288 | completed: false, 289 | message: `Invalid DNS server address format: ${dnsServer}` 290 | }; 291 | } 292 | 293 | // Set DNS server 294 | levelState.dns.configured = true; 295 | levelState.dns.server = dnsServer; 296 | 297 | return { 298 | completed: false, 299 | message: `DNS server set to ${dnsServer}.` 300 | }; 301 | } 302 | 303 | if (cmd === 'ping' && parts.length > 1) { 304 | const host = parts[1]; 305 | 306 | // Check if we have a working network interface 307 | const hasNetworkInterface = levelState.interfaces.some(iface => 308 | iface.status === 'UP' && iface.ip && iface.ip !== '127.0.0.1' 309 | ); 310 | 311 | if (!hasNetworkInterface) { 312 | return { 313 | completed: false, 314 | message: 'Network is unreachable. Configure a network interface first.' 315 | }; 316 | } 317 | 318 | // Check if we have a gateway configured 319 | if (!levelState.gateway.configured) { 320 | return { 321 | completed: false, 322 | message: 'Network is unreachable. Configure a default gateway first.' 323 | }; 324 | } 325 | 326 | // If pinging the escape portal 327 | if (host === levelState.escapePortal.host) { 328 | if (!levelState.dns.configured) { 329 | return { 330 | completed: false, 331 | message: `ping: unknown host ${host}. Configure DNS first.` 332 | }; 333 | } 334 | 335 | return { 336 | completed: false, 337 | message: `PING ${host} (${levelState.escapePortal.ip}): 56 data bytes\n64 bytes from ${levelState.escapePortal.ip}: icmp_seq=0 ttl=64 time=0.1 ms\n64 bytes from ${levelState.escapePortal.ip}: icmp_seq=1 ttl=64 time=0.1 ms\n\n--- ${host} ping statistics ---\n2 packets transmitted, 2 packets received, 0.0% packet loss\nround-trip min/avg/max/stddev = 0.1/0.1/0.1/0.0 ms` 338 | }; 339 | } else if (host === levelState.escapePortal.ip) { 340 | return { 341 | completed: false, 342 | message: `PING ${host}: 56 data bytes\n64 bytes from ${host}: icmp_seq=0 ttl=64 time=0.1 ms\n64 bytes from ${host}: icmp_seq=1 ttl=64 time=0.1 ms\n\n--- ${host} ping statistics ---\n2 packets transmitted, 2 packets received, 0.0% packet loss\nround-trip min/avg/max/stddev = 0.1/0.1/0.1/0.0 ms` 343 | }; 344 | } 345 | 346 | return { 347 | completed: false, 348 | message: `ping: cannot resolve ${host}: Unknown host` 349 | }; 350 | } 351 | 352 | if (cmd === 'nslookup' && parts.length > 1) { 353 | const host = parts[1]; 354 | 355 | if (!levelState.dns.configured) { 356 | return { 357 | completed: false, 358 | message: `nslookup: can't resolve '${host}': No DNS servers configured` 359 | }; 360 | } 361 | 362 | if (host === levelState.escapePortal.host) { 363 | return { 364 | completed: false, 365 | message: `Server:\t${levelState.dns.server}\nAddress:\t${levelState.dns.server}#53\n\nNon-authoritative answer:\nName:\t${host}\nAddress: ${levelState.escapePortal.ip}` 366 | }; 367 | } 368 | 369 | return { 370 | completed: false, 371 | message: `Server:\t${levelState.dns.server}\nAddress:\t${levelState.dns.server}#53\n\n** server can't find ${host}: NXDOMAIN` 372 | }; 373 | } 374 | 375 | if (cmd === 'connect' && parts.length > 2) { 376 | const host = parts[1]; 377 | const port = parseInt(parts[2]); 378 | 379 | if (isNaN(port) || port < 1 || port > 65535) { 380 | return { 381 | completed: false, 382 | message: `Invalid port number: ${parts[2]}` 383 | }; 384 | } 385 | 386 | // Check if we have a working network interface 387 | const hasNetworkInterface = levelState.interfaces.some(iface => 388 | iface.status === 'UP' && iface.ip && iface.ip !== '127.0.0.1' 389 | ); 390 | 391 | if (!hasNetworkInterface) { 392 | return { 393 | completed: false, 394 | message: 'Network is unreachable. Configure a network interface first.' 395 | }; 396 | } 397 | 398 | // Check if we have a gateway configured 399 | if (!levelState.gateway.configured) { 400 | return { 401 | completed: false, 402 | message: 'Network is unreachable. Configure a default gateway first.' 403 | }; 404 | } 405 | 406 | // Resolve host if needed 407 | let resolvedIp = host; 408 | if (host === levelState.escapePortal.host) { 409 | if (!levelState.dns.configured) { 410 | return { 411 | completed: false, 412 | message: `connect: could not resolve ${host}: Name or service not known` 413 | }; 414 | } 415 | resolvedIp = levelState.escapePortal.ip; 416 | } 417 | 418 | // Check if this is the escape portal 419 | const isEscapePortal = (resolvedIp === levelState.escapePortal.ip && port === levelState.escapePortal.port); 420 | 421 | // Check if firewall allows this connection 422 | if (levelState.firewall.enabled) { 423 | const rule = levelState.firewall.rules.find(r => r.port === port); 424 | if (rule && rule.action === 'DENY') { 425 | return { 426 | completed: false, 427 | message: `connect: Connection refused (blocked by firewall)` 428 | }; 429 | } 430 | } 431 | 432 | if (isEscapePortal) { 433 | // Success! Complete the level 434 | return { 435 | completed: true, 436 | message: `Connected to escape portal at ${host}:${port}!\n\nWelcome to the escape portal. You have successfully configured the network and escaped the isolated system.\n\nCongratulations on completing all levels!`, 437 | nextAction: 'main_menu' 438 | }; 439 | } 440 | 441 | // Add to connections list 442 | levelState.connections.push({ 443 | protocol: 'tcp', 444 | localAddress: levelState.interfaces.find(i => i.status === 'UP' && i.ip !== '127.0.0.1')?.ip || '0.0.0.0', 445 | localPort: 12345 + levelState.connections.length, 446 | remoteAddress: resolvedIp, 447 | remotePort: port 448 | }); 449 | 450 | return { 451 | completed: false, 452 | message: `Connected to ${host}:${port}, but nothing interesting happened.` 453 | }; 454 | } 455 | 456 | return { 457 | completed: false, 458 | message: 'Unknown command or invalid syntax.' 459 | }; 460 | }, 461 | 462 | hints: [ 463 | 'First bring up a network interface with "ifup eth0"', 464 | 'Configure the interface with "ifconfig eth0 10.0.0.2 255.255.255.0"', 465 | 'Set up a default gateway with "route add default 10.0.0.254"', 466 | 'Configure DNS with "echo nameserver 10.0.0.254 > /etc/resolv.conf"', 467 | 'Allow the escape portal port with "firewall-cmd --allow 8080"', 468 | 'Connect to the escape portal with "connect escape.portal 8080"' 469 | ] 470 | }; 471 | 472 | export function registerLevel5() { 473 | registerLevel(level); 474 | } -------------------------------------------------------------------------------- /src/ui/commandHistory.ts: -------------------------------------------------------------------------------- 1 | // Maximum number of commands to store in history 2 | const MAX_HISTORY_SIZE = 50; 3 | 4 | // Command history for each player 5 | const commandHistories: Record = {}; 6 | let currentHistoryIndex = -1; 7 | let currentInput = ''; 8 | 9 | // Initialize command history for a player 10 | export function initCommandHistory(playerName: string): void { 11 | if (!commandHistories[playerName]) { 12 | commandHistories[playerName] = []; 13 | } 14 | currentHistoryIndex = -1; 15 | currentInput = ''; 16 | } 17 | 18 | // Add a command to history 19 | export function addToHistory(playerName: string, command: string): void { 20 | if (!commandHistories[playerName]) { 21 | initCommandHistory(playerName); 22 | } 23 | 24 | // Don't add empty commands or duplicates of the last command 25 | if (command.trim() === '' || 26 | (commandHistories[playerName].length > 0 && 27 | commandHistories[playerName][0] === command)) { 28 | return; 29 | } 30 | 31 | // Add to the beginning of the array 32 | commandHistories[playerName].unshift(command); 33 | 34 | // Trim history if it gets too long 35 | if (commandHistories[playerName].length > MAX_HISTORY_SIZE) { 36 | commandHistories[playerName].pop(); 37 | } 38 | 39 | // Reset index 40 | currentHistoryIndex = -1; 41 | currentInput = ''; 42 | } 43 | 44 | // Get previous command from history 45 | export function getPreviousCommand(playerName: string, currentCommand: string): string { 46 | if (!commandHistories[playerName] || commandHistories[playerName].length === 0) { 47 | return currentCommand; 48 | } 49 | 50 | // Save current input if we're just starting to navigate history 51 | if (currentHistoryIndex === -1) { 52 | currentInput = currentCommand; 53 | } 54 | 55 | // Move back in history 56 | currentHistoryIndex = Math.min(currentHistoryIndex + 1, commandHistories[playerName].length - 1); 57 | return commandHistories[playerName][currentHistoryIndex]; 58 | } 59 | 60 | // Get next command from history 61 | export function getNextCommand(playerName: string): string { 62 | if (!commandHistories[playerName] || currentHistoryIndex === -1) { 63 | return currentInput; 64 | } 65 | 66 | // Move forward in history 67 | currentHistoryIndex = Math.max(currentHistoryIndex - 1, -1); 68 | 69 | // Return original input if we've reached the end of history 70 | if (currentHistoryIndex === -1) { 71 | return currentInput; 72 | } 73 | 74 | return commandHistories[playerName][currentHistoryIndex]; 75 | } 76 | 77 | // Get all commands in history 78 | export function getCommandHistory(playerName: string): string[] { 79 | return commandHistories[playerName] || []; 80 | } 81 | 82 | // Clear command history 83 | export function clearCommandHistory(playerName: string): void { 84 | commandHistories[playerName] = []; 85 | currentHistoryIndex = -1; 86 | currentInput = ''; 87 | } -------------------------------------------------------------------------------- /src/ui/entryMenu.ts: -------------------------------------------------------------------------------- 1 | import { clearScreen, promptInput, drawBox } from './uiHelpers'; 2 | import { generateLogo, getTheme, bootSequence } from './visualEffects'; 3 | import { loadProfile, createProfile, listProfiles } from '../core/playerProfile'; 4 | import { createNewGame, loadGame } from '../core/gameState'; 5 | import { renderMainMenu } from './mainMenu'; 6 | import { successAnimation, loadingAnimation } from './visualEffects'; 7 | import { listSaves } from '../core/gameInit'; 8 | 9 | // Track if we've shown the boot sequence 10 | let bootSequenceShown = false; 11 | 12 | export async function renderEntryMenu(): Promise { 13 | // Show boot sequence only once 14 | if (!bootSequenceShown) { 15 | await bootSequence(); 16 | bootSequenceShown = true; 17 | } else { 18 | clearScreen(); 19 | console.log(generateLogo()); 20 | console.log(''); 21 | } 22 | 23 | const theme = getTheme(); 24 | 25 | while (true) { 26 | clearScreen(); 27 | console.log(generateLogo()); 28 | console.log(theme.secondary('A Linux Terminal Escape Room Game')); 29 | console.log(''); 30 | 31 | const menuOptions = [ 32 | '1. ' + theme.accent('New Game'), 33 | '2. ' + theme.accent('Load Game'), 34 | '3. ' + theme.accent('Exit') 35 | ]; 36 | 37 | console.log(drawBox('WELCOME', menuOptions.join('\n'))); 38 | console.log(''); 39 | 40 | const choice = await promptInput('Select an option: '); 41 | 42 | if (choice === '1') { 43 | const success = await newGameMenu(); 44 | if (success) { 45 | await renderMainMenu(); 46 | return; 47 | } 48 | } else if (choice === '2') { 49 | const success = await loadGameMenu(); 50 | if (success) { 51 | await renderMainMenu(); 52 | return; 53 | } 54 | } else if (choice === '3') { 55 | console.log('Thanks for playing ShellQuest!'); 56 | process.exit(0); 57 | } else { 58 | console.log(theme.error('Invalid option. Press Enter to continue...')); 59 | await promptInput(''); 60 | } 61 | } 62 | } 63 | 64 | async function newGameMenu(): Promise { 65 | const theme = getTheme(); 66 | 67 | clearScreen(); 68 | console.log(theme.accent('=== NEW GAME ===')); 69 | console.log(''); 70 | 71 | const playerName = await promptInput('Enter your name: '); 72 | 73 | if (!playerName) { 74 | console.log(theme.error('Name cannot be empty.')); 75 | await promptInput('Press Enter to continue...'); 76 | return false; 77 | } 78 | 79 | await loadingAnimation('Creating new game...', 1000); 80 | 81 | // Create a new game state 82 | const gameState = createNewGame(playerName); 83 | 84 | await successAnimation('Game created successfully!'); 85 | return true; 86 | } 87 | 88 | async function loadGameMenu(): Promise { 89 | const theme = getTheme(); 90 | 91 | clearScreen(); 92 | console.log(theme.accent('=== LOAD GAME ===')); 93 | console.log(''); 94 | 95 | // Get list of save files (just player names) 96 | const saveFiles = await listSaves(); 97 | 98 | if (saveFiles.length === 0) { 99 | console.log(theme.warning('No saved games found.')); 100 | await promptInput('Press Enter to continue...'); 101 | return false; 102 | } 103 | 104 | console.log('Available players:'); 105 | saveFiles.forEach((save, index) => { 106 | console.log(`${index + 1}. ${theme.accent(save)}`); 107 | }); 108 | console.log(''); 109 | 110 | const choice = await promptInput('Select a player (or 0 to cancel): '); 111 | const choiceNum = parseInt(choice); 112 | 113 | if (choiceNum === 0 || isNaN(choiceNum) || choiceNum > saveFiles.length) { 114 | return false; 115 | } 116 | 117 | const playerName = saveFiles[choiceNum - 1]; 118 | 119 | // Try to load the save 120 | await loadingAnimation('Loading game...', 1000); 121 | const success = await loadGame(playerName); 122 | 123 | if (success) { 124 | await successAnimation('Game loaded successfully!'); 125 | return true; 126 | } else { 127 | console.log(theme.error('Failed to load game.')); 128 | await promptInput('Press Enter to continue...'); 129 | return false; 130 | } 131 | } -------------------------------------------------------------------------------- /src/ui/gameUI.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentGameState, saveGame } from "../core/gameState"; 2 | import { 3 | getLevelById, 4 | completeCurrentLevel, 5 | getAllLevels, 6 | } from "../core/levelSystem"; 7 | import { renderMainMenu } from "./mainMenu"; 8 | import { 9 | clearScreen, 10 | promptInput, 11 | styles, 12 | drawBox, 13 | drawTable, 14 | } from "./uiHelpers"; 15 | import { 16 | getTheme, 17 | successAnimation, 18 | typewriter, 19 | loadingAnimation, 20 | } from "./visualEffects"; 21 | import { playSound } from "./soundEffects"; 22 | import { levelUI } from "./levelRenderer"; 23 | import { addToHistory } from "./commandHistory"; 24 | import { triggerAchievement } from "../core/achievements"; 25 | 26 | export async function renderGameUI(): Promise { 27 | const gameState = getCurrentGameState(); 28 | if (!gameState) { 29 | console.error(styles.error("No active game")); 30 | await renderMainMenu(); 31 | return; 32 | } 33 | 34 | const theme = getTheme(); 35 | 36 | // Game loop 37 | while (true) { 38 | // Get the current level at the start of each loop iteration 39 | const currentLevel = getLevelById(gameState.currentLevel); 40 | if (!currentLevel) { 41 | console.error(theme.error(`Level ${gameState.currentLevel} not found`)); 42 | await renderMainMenu(); 43 | return; 44 | } 45 | 46 | clearScreen(); 47 | 48 | // Display game header 49 | console.log( 50 | drawBox( 51 | `SHELLQUEST - ${theme.accent(currentLevel.name)}`, 52 | `Player: ${theme.accent(gameState.playerName)}\nLevel: ${ 53 | gameState.currentLevel 54 | }/${getAllLevels().length}` 55 | ) 56 | ); 57 | console.log(""); 58 | 59 | // Render current level in a box 60 | await levelUI.levelContent(currentLevel.name, async () => { 61 | await currentLevel.render(); 62 | }); 63 | 64 | console.log(""); 65 | console.log(theme.secondary("Available commands:")); 66 | console.log( 67 | `${theme.accent("/help")} - Show help, ${theme.accent( 68 | "/save" 69 | )} - Save game, ${theme.accent("/menu")} - Main menu, ${theme.accent( 70 | "/hint" 71 | )} - Get a hint` 72 | ); 73 | console.log(""); 74 | 75 | // Display input box and get player input 76 | levelUI.inputBox(); 77 | const input = await promptInput(""); 78 | 79 | if (input.trim()) { 80 | addToHistory(gameState.playerName, input); 81 | } 82 | 83 | // Handle special commands 84 | if (input.startsWith("/")) { 85 | const command = input.slice(1).toLowerCase(); 86 | 87 | if (command === "help") { 88 | await showHelp(); 89 | continue; 90 | } 91 | 92 | if (command === "save") { 93 | const result = await saveGame(); 94 | if (result.success) { 95 | console.log(theme.success(result.message)); 96 | } else { 97 | console.log(theme.error(result.message)); 98 | } 99 | await promptInput("Press Enter to continue..."); 100 | continue; 101 | } 102 | 103 | if (command === "menu") { 104 | await renderMainMenu(); 105 | return; 106 | } 107 | 108 | if (command === "hint") { 109 | await triggerAchievement("hint_used"); 110 | await showHint(currentLevel.hints); 111 | continue; 112 | } 113 | } 114 | 115 | // Process level-specific input 116 | const result = await currentLevel.handleInput(input); 117 | 118 | if (result.message) { 119 | console.log(""); 120 | await typewriter(result.message, 5); 121 | await promptInput("Press Enter to continue..."); 122 | } 123 | 124 | if (result.completed) { 125 | playSound("levelComplete"); 126 | await completeCurrentLevel(); 127 | 128 | // Trigger level completion achievement 129 | await triggerAchievement("level_completed", { 130 | levelId: gameState.currentLevel, 131 | usedHint: 132 | gameState.levelStates[gameState.currentLevel]?.usedHint || false, 133 | timeSpent: 134 | gameState.levelStates[gameState.currentLevel]?.timeSpent || 0, 135 | allLevels: getAllLevels().length, 136 | }); 137 | 138 | await successAnimation("Level completed!"); 139 | 140 | if (result.nextAction === "main_menu") { 141 | await renderMainMenu(); 142 | return; 143 | } else if (result.nextAction === "next_level") { 144 | const nextLevelId = gameState.currentLevel + 1; 145 | const nextLevel = getLevelById(nextLevelId); 146 | 147 | if (nextLevel) { 148 | gameState.currentLevel = nextLevelId; 149 | // Initialize the next level before loading it 150 | await nextLevel.initialize(); 151 | await loadingAnimation("Loading next level...", 1500); 152 | } else { 153 | // Game completed 154 | clearScreen(); 155 | console.log( 156 | theme.success( 157 | "🎉 Congratulations! You have completed all levels! 🎉" 158 | ) 159 | ); 160 | await typewriter( 161 | "You have proven yourself to be a master of the terminal.", 162 | 20 163 | ); 164 | await promptInput("Press Enter to return to the main menu..."); 165 | await renderMainMenu(); 166 | return; 167 | } 168 | } 169 | } 170 | 171 | // When using a command, track it for achievements 172 | await triggerAchievement("command_used", { command: input }); 173 | } 174 | } 175 | 176 | async function showHelp(): Promise { 177 | const theme = getTheme(); 178 | 179 | clearScreen(); 180 | console.log(theme.accent("=== Help ===")); 181 | console.log(""); 182 | console.log( 183 | "ShellQuest is a puzzle game where you solve Linux-themed challenges." 184 | ); 185 | console.log(""); 186 | console.log(theme.secondary("Special Commands:")); 187 | console.log(`${theme.accent("/help")} - Show this help screen`); 188 | console.log(`${theme.accent("/save")} - Save your game`); 189 | console.log(`${theme.accent("/menu")} - Return to main menu`); 190 | console.log(`${theme.accent("/hint")} - Get a hint for the current level`); 191 | console.log(""); 192 | console.log("Each level has its own commands and puzzles to solve."); 193 | console.log(""); 194 | await promptInput("Press Enter to continue..."); 195 | } 196 | 197 | async function showHint(hints: string[]): Promise { 198 | const gameState = getCurrentGameState(); 199 | if (!gameState) return; 200 | 201 | const theme = getTheme(); 202 | 203 | // Get level state for hints 204 | const levelState = gameState.levelStates[gameState.currentLevel] || {}; 205 | const hintIndex = levelState.hintIndex || 0; 206 | 207 | clearScreen(); 208 | console.log(theme.accent("=== Hint ===")); 209 | console.log(""); 210 | 211 | if (hintIndex < hints.length) { 212 | await typewriter(hints[hintIndex], 20); 213 | 214 | // Update hint index for next time 215 | gameState.levelStates[gameState.currentLevel] = { 216 | ...levelState, 217 | hintIndex: hintIndex + 1, 218 | }; 219 | } else { 220 | console.log(theme.warning("No more hints available for this level.")); 221 | } 222 | 223 | console.log(""); 224 | await promptInput("Press Enter to continue..."); 225 | } 226 | -------------------------------------------------------------------------------- /src/ui/levelRenderer.ts: -------------------------------------------------------------------------------- 1 | import { styles, drawBox, drawTable } from './uiHelpers'; 2 | import { getTheme } from './visualEffects'; 3 | 4 | // Helper function to wrap text to fit within a width 5 | function wrapText(text: string, maxWidth: number): string[] { 6 | const words = text.split(' '); 7 | const lines: string[] = []; 8 | let currentLine = ''; 9 | 10 | words.forEach(word => { 11 | // If adding this word would exceed the max width 12 | if ((currentLine + ' ' + word).length > maxWidth && currentLine.length > 0) { 13 | lines.push(currentLine); 14 | currentLine = word; 15 | } else { 16 | // Add word to current line (with a space if not the first word) 17 | currentLine = currentLine.length === 0 ? word : currentLine + ' ' + word; 18 | } 19 | }); 20 | 21 | // Add the last line 22 | if (currentLine.length > 0) { 23 | lines.push(currentLine); 24 | } 25 | 26 | return lines; 27 | } 28 | 29 | export const levelUI = { 30 | title: (text: string) => { 31 | const theme = getTheme(); 32 | console.log(theme.accent(text)); 33 | }, 34 | 35 | subtitle: (text: string) => { 36 | const theme = getTheme(); 37 | console.log(theme.secondary(text)); 38 | }, 39 | 40 | paragraph: (text: string) => console.log(text), 41 | 42 | spacer: () => console.log(''), 43 | 44 | box: (title: string, content: string) => { 45 | const theme = getTheme(); 46 | console.log(drawBox(theme.accent(title), content)); 47 | }, 48 | 49 | // Improved level content box 50 | levelContent: (title: string, content: () => Promise) => { 51 | const theme = getTheme(); 52 | const boxWidth = 76; 53 | 54 | // Start capturing console output 55 | const originalLog = console.log; 56 | let capturedOutput: string[] = []; 57 | 58 | console.log = (...args) => { 59 | capturedOutput.push(args.join(' ')); 60 | }; 61 | 62 | // Execute the content function and then process the output 63 | return content().then(() => { 64 | // Restore console.log 65 | console.log = originalLog; 66 | 67 | // Process and format the captured output 68 | let formattedLines: string[] = []; 69 | 70 | capturedOutput.forEach(line => { 71 | // Skip empty lines at the beginning 72 | if (formattedLines.length === 0 && line.trim() === '') { 73 | return; 74 | } 75 | 76 | // Handle long lines by wrapping them 77 | if (line.length > boxWidth - 4) { 78 | const wrappedLines = wrapText(line, boxWidth - 4); 79 | formattedLines.push(...wrappedLines); 80 | } else { 81 | formattedLines.push(line); 82 | } 83 | }); 84 | 85 | // Draw the box with the formatted content 86 | console.log('┌' + '─'.repeat(boxWidth - 2) + '┐'); 87 | 88 | // Title bar 89 | console.log('│ ' + theme.accent(title.padEnd(boxWidth - 4)) + ' │'); 90 | console.log('├' + '─'.repeat(boxWidth - 2) + '┤'); 91 | 92 | // Content 93 | formattedLines.forEach(line => { 94 | console.log('│ ' + line.padEnd(boxWidth - 4) + ' │'); 95 | }); 96 | 97 | // Bottom of box 98 | console.log('└' + '─'.repeat(boxWidth - 2) + '┘'); 99 | }); 100 | }, 101 | 102 | // Revert to the simpler input box 103 | inputBox: () => { 104 | const theme = getTheme(); 105 | console.log('Enter your command below:'); 106 | }, 107 | 108 | terminal: (content: string) => { 109 | const theme = getTheme(); 110 | console.log(' ' + theme.secondary('┌─ Terminal ───────────────────────┐')); 111 | content.split('\n').forEach(line => { 112 | console.log(' ' + theme.secondary('│') + ' ' + theme.accent(line)); 113 | }); 114 | console.log(' ' + theme.secondary('└────────────────────────────────────┘')); 115 | }, 116 | 117 | fileSystem: (path: string, items: {name: string, type: string}[]) => { 118 | const theme = getTheme(); 119 | console.log(theme.info(`Current directory: ${path}`)); 120 | console.log(''); 121 | 122 | if (items.length === 0) { 123 | console.log(theme.secondary(' (empty directory)')); 124 | return; 125 | } 126 | 127 | items.forEach(item => { 128 | const icon = item.type === 'dir' ? '📁' : '📄'; 129 | console.log(` ${icon} ${item.type === 'dir' ? theme.accent(item.name) : item.name}`); 130 | }); 131 | }, 132 | 133 | processTable: (processes: any[]) => { 134 | const headers = ['PID', 'NAME', 'CPU%', 'MEM%', 'STATUS']; 135 | const rows = processes.map(p => [ 136 | p.pid.toString(), 137 | p.name, 138 | p.cpu.toFixed(1), 139 | p.memory.toFixed(1), 140 | p.status 141 | ]); 142 | 143 | console.log(drawTable(headers, rows)); 144 | }, 145 | 146 | commands: (commands: string[]) => { 147 | const theme = getTheme(); 148 | console.log(theme.secondary('Available Commands:')); 149 | commands.forEach(cmd => console.log(' ' + theme.accent(cmd))); 150 | } 151 | }; -------------------------------------------------------------------------------- /src/ui/mainMenu.ts: -------------------------------------------------------------------------------- 1 | import { createNewGame, loadGame, getCurrentGameState } from '../core/gameState'; 2 | import { listSaves } from '../core/gameInit'; 3 | import { getLeaderboard, formatTime } from '../core/leaderboard'; 4 | import { startLevel, getAllLevels } from '../core/levelSystem'; 5 | import { renderGameUI } from './gameUI'; 6 | import { clearScreen, promptInput, styles, drawBox } from './uiHelpers'; 7 | import { 8 | generateLogo, 9 | getTheme, 10 | setTheme, 11 | themes, 12 | bootSequence, 13 | animateText, 14 | successAnimation, 15 | loadingAnimation 16 | } from './visualEffects'; 17 | import { showAchievements } from '../core/achievements'; 18 | import { renderProgressMap } from './progressMap'; 19 | import { 20 | toggleSound, 21 | toggleAmbientSound, 22 | toggleSoundEffects, 23 | setSoundVolume, 24 | soundConfig, 25 | initSoundSystem 26 | } from './soundEffects'; 27 | import { addToHistory } from './commandHistory'; 28 | import { renderEntryMenu } from './entryMenu'; 29 | 30 | // Track if we've shown the boot sequence 31 | let bootSequenceShown = false; 32 | 33 | // Initialize sound system in the main menu 34 | initSoundSystem(); 35 | 36 | export async function renderMainMenu(): Promise { 37 | const gameState = getCurrentGameState(); 38 | if (!gameState) { 39 | // If no game state, go back to entry menu 40 | await renderEntryMenu(); 41 | return; 42 | } 43 | 44 | const theme = getTheme(); 45 | 46 | while (true) { 47 | clearScreen(); 48 | console.log(generateLogo()); 49 | console.log(theme.secondary('A Linux Terminal Escape Room Game')); 50 | console.log(''); 51 | 52 | const menuOptions = [ 53 | '1. ' + theme.accent('Continue Game'), 54 | '2. ' + theme.accent('Achievements'), 55 | '3. ' + theme.accent('Progress Map'), 56 | '4. ' + theme.accent('Leaderboard'), 57 | '5. ' + theme.accent('Settings'), 58 | '6. ' + theme.accent('Back to Entry Menu'), 59 | '7. ' + theme.accent('Exit') 60 | ]; 61 | 62 | console.log(drawBox('MAIN MENU', menuOptions.join('\n'))); 63 | console.log(''); 64 | console.log(theme.info(`Player: ${gameState.playerName}`)); 65 | console.log(''); 66 | 67 | const choice = await promptInput('Select an option: '); 68 | 69 | if (choice === '1') { 70 | // Continue game 71 | startLevel(gameState.currentLevel); 72 | await renderGameUI(); 73 | } else if (choice === '2') { 74 | // Show achievements 75 | await showAchievements(); 76 | await promptInput('Press Enter to continue...'); 77 | } else if (choice === '3') { 78 | // Show progress map 79 | clearScreen(); 80 | await renderProgressMap(); 81 | await promptInput('Press Enter to continue...'); 82 | } else if (choice === '4') { 83 | // Show leaderboard 84 | await showLeaderboard(); 85 | } else if (choice === '5') { 86 | // Settings 87 | await showSettings(); 88 | } else if (choice === '6') { 89 | // Back to entry menu 90 | await renderEntryMenu(); 91 | return; 92 | } else if (choice === '7') { 93 | // Exit 94 | await animateText('Thanks for playing ShellQuest!', 30); 95 | process.exit(0); 96 | } else { 97 | console.log(theme.error('Invalid option. Press Enter to continue...')); 98 | await promptInput(''); 99 | } 100 | } 101 | } 102 | 103 | // Add a new settings menu 104 | async function showSettings(): Promise { 105 | const theme = getTheme(); 106 | 107 | while (true) { 108 | clearScreen(); 109 | console.log(theme.accent('=== SETTINGS ===')); 110 | console.log(''); 111 | 112 | console.log('1. ' + theme.accent('Change Theme')); 113 | console.log('2. ' + theme.accent('Back to Main Menu')); 114 | console.log(''); 115 | 116 | const choice = await promptInput('Select an option: '); 117 | 118 | if (choice === '1') { 119 | await changeTheme(); 120 | } else if (choice === '2') { 121 | return; 122 | } else { 123 | console.log(theme.error('Invalid option. Press Enter to continue...')); 124 | await promptInput(''); 125 | } 126 | } 127 | } 128 | 129 | // Add a theme selection menu 130 | async function changeTheme(): Promise { 131 | const theme = getTheme(); 132 | 133 | clearScreen(); 134 | console.log(theme.accent('=== SELECT THEME ===')); 135 | console.log(''); 136 | 137 | Object.keys(themes).forEach((themeName, index) => { 138 | console.log(`${index + 1}. ${theme.accent(themes[themeName].name)}`); 139 | }); 140 | 141 | console.log(''); 142 | const choice = await promptInput('Select a theme: '); 143 | const themeIndex = parseInt(choice) - 1; 144 | 145 | if (themeIndex >= 0 && themeIndex < Object.keys(themes).length) { 146 | const selectedTheme = Object.keys(themes)[themeIndex] as keyof typeof themes; 147 | setTheme(selectedTheme); 148 | await successAnimation('Theme changed successfully!'); 149 | } else { 150 | console.log(theme.error('Invalid theme selection.')); 151 | await promptInput('Press Enter to continue...'); 152 | } 153 | } 154 | 155 | async function showLeaderboard(): Promise { 156 | const theme = getTheme(); 157 | 158 | clearScreen(); 159 | console.log(theme.accent('=== LEADERBOARD ===')); 160 | console.log(''); 161 | 162 | const leaderboard = await getLeaderboard(); 163 | 164 | if (leaderboard.players.length === 0) { 165 | console.log(theme.warning('No entries yet. Be the first to complete the game!')); 166 | } else { 167 | console.log('Top Players:'); 168 | console.log('-----------'); 169 | leaderboard.players.slice(0, 10).forEach((entry, index) => { 170 | console.log(`${index + 1}. ${theme.accent(entry.playerName)} - ${formatTime(entry.completionTime)}`); 171 | }); 172 | } 173 | 174 | console.log(''); 175 | await promptInput('Press Enter to return to main menu...'); 176 | } 177 | 178 | // Add this function to the mainMenu.ts file 179 | async function soundSettings(): Promise { 180 | const theme = getTheme(); 181 | 182 | while (true) { 183 | clearScreen(); 184 | console.log(theme.accent('=== SOUND SETTINGS ===')); 185 | console.log(''); 186 | 187 | console.log(`1. Sound: ${soundConfig.enabled ? theme.success('ON') : theme.error('OFF')}`); 188 | console.log(`2. Ambient Sound: ${soundConfig.ambientEnabled ? theme.success('ON') : theme.error('OFF')}`); 189 | console.log(`3. Sound Effects: ${soundConfig.effectsEnabled ? theme.success('ON') : theme.error('OFF')}`); 190 | console.log(`4. Volume: ${Math.round(soundConfig.volume * 100)}%`); 191 | console.log('5. Back to Settings'); 192 | console.log(''); 193 | 194 | const choice = await promptInput('Select an option: '); 195 | 196 | switch (choice) { 197 | case '1': 198 | toggleSound(); 199 | break; 200 | case '2': 201 | toggleAmbientSound(); 202 | break; 203 | case '3': 204 | toggleSoundEffects(); 205 | break; 206 | case '4': 207 | await changeVolume(); 208 | break; 209 | case '5': 210 | return; 211 | default: 212 | console.log(theme.error('Invalid option. Press Enter to continue...')); 213 | await promptInput(''); 214 | } 215 | } 216 | } 217 | 218 | // Add this function to change volume 219 | async function changeVolume(): Promise { 220 | const theme = getTheme(); 221 | 222 | clearScreen(); 223 | console.log(theme.accent('=== VOLUME SETTINGS ===')); 224 | console.log(''); 225 | 226 | console.log('Current volume: ' + Math.round(soundConfig.volume * 100) + '%'); 227 | console.log('Enter a value between 0 and 100:'); 228 | 229 | const input = await promptInput(''); 230 | const volume = parseInt(input); 231 | 232 | if (isNaN(volume) || volume < 0 || volume > 100) { 233 | console.log(theme.error('Invalid volume. Please enter a number between 0 and 100.')); 234 | await promptInput('Press Enter to continue...'); 235 | return; 236 | } 237 | 238 | setSoundVolume(volume / 100); 239 | console.log(theme.success(`Volume set to ${volume}%`)); 240 | await promptInput('Press Enter to continue...'); 241 | } -------------------------------------------------------------------------------- /src/ui/progressMap.ts: -------------------------------------------------------------------------------- 1 | import { getAllLevels } from '../core/levelSystem'; 2 | import { getCurrentProfile } from '../core/playerProfile'; 3 | import { getTheme } from './visualEffects'; 4 | 5 | export async function renderProgressMap(): Promise { 6 | const profile = await getCurrentProfile(); 7 | if (!profile) { 8 | console.log('No active player profile. Please start a game first.'); 9 | return; 10 | } 11 | 12 | const theme = getTheme(); 13 | const allLevels = getAllLevels(); 14 | const completedLevels = profile.completedLevels; 15 | const currentLevelId = Math.max(...completedLevels) + 1; 16 | 17 | console.log(theme.accent('=== Progress Map ===')); 18 | console.log(''); 19 | 20 | // Calculate the maximum level name length for formatting 21 | const maxNameLength = Math.max(...allLevels.map(level => level.name.length)); 22 | 23 | // Create a visual map of levels 24 | console.log('┌' + '─'.repeat(maxNameLength + 22) + '┐'); 25 | 26 | allLevels.forEach((level, index) => { 27 | const levelNumber = level.id; 28 | const isCurrentLevel = levelNumber === currentLevelId; 29 | const isCompleted = levelNumber < currentLevelId; 30 | const isLocked = levelNumber > currentLevelId; 31 | 32 | let statusIcon; 33 | let levelName; 34 | 35 | if (isCompleted) { 36 | statusIcon = theme.success('✓'); 37 | levelName = theme.success(level.name.padEnd(maxNameLength)); 38 | } else if (isCurrentLevel) { 39 | statusIcon = theme.accent('▶'); 40 | levelName = theme.accent(level.name.padEnd(maxNameLength)); 41 | } else if (isLocked) { 42 | statusIcon = theme.secondary('🔒'); 43 | levelName = theme.secondary(level.name.padEnd(maxNameLength)); 44 | } 45 | 46 | console.log(`│ ${statusIcon} Level ${levelNumber.toString().padStart(2)} │ ${levelName} │`); 47 | 48 | // Add connector line between levels 49 | if (index < allLevels.length - 1) { 50 | console.log('│ ' + ' '.repeat(maxNameLength + 20) + '│'); 51 | console.log('│ ' + theme.secondary('│').padStart(7) + ' '.repeat(maxNameLength + 14) + '│'); 52 | console.log('│ ' + theme.secondary('▼').padStart(7) + ' '.repeat(maxNameLength + 14) + '│'); 53 | console.log('│ ' + ' '.repeat(maxNameLength + 20) + '│'); 54 | } 55 | }); 56 | 57 | console.log('└' + '─'.repeat(maxNameLength + 22) + '┘'); 58 | 59 | // Show completion percentage 60 | const completedLevelsCount = completedLevels.length; 61 | const completionPercentage = Math.round((completedLevelsCount / allLevels.length) * 100); 62 | 63 | console.log(''); 64 | console.log(`Overall Progress: ${completedLevelsCount}/${allLevels.length} levels completed (${completionPercentage}%)`); 65 | 66 | // Visual progress bar 67 | const progressBarWidth = 40; 68 | const filledWidth = Math.round((completionPercentage / 100) * progressBarWidth); 69 | const emptyWidth = progressBarWidth - filledWidth; 70 | 71 | const progressBar = '[' + 72 | theme.success('='.repeat(filledWidth)) + 73 | theme.secondary('-'.repeat(emptyWidth)) + 74 | '] ' + completionPercentage + '%'; 75 | 76 | console.log(progressBar); 77 | } -------------------------------------------------------------------------------- /src/ui/soundEffects.ts: -------------------------------------------------------------------------------- 1 | // Simplified sound effects module that doesn't actually play sounds 2 | // but maintains the interface for the rest of the application 3 | 4 | export const soundConfig = { 5 | enabled: false, 6 | volume: 0.5, 7 | ambientEnabled: false, 8 | effectsEnabled: false 9 | }; 10 | 11 | // Play a sound effect (does nothing) 12 | export function playSound(sound: 'success' | 'error' | 'typing' | 'levelComplete'): void { 13 | // No-op function to maintain API compatibility 14 | } 15 | 16 | // Play ambient sound (does nothing) 17 | export function playAmbientSound(): void { 18 | // No-op function to maintain API compatibility 19 | } 20 | 21 | // Stop the ambient sound (does nothing) 22 | export function stopAmbientSound(): void { 23 | // No-op function to maintain API compatibility 24 | } 25 | 26 | // Toggle sound on/off 27 | export function toggleSound(): boolean { 28 | return soundConfig.enabled; 29 | } 30 | 31 | // Toggle ambient sound on/off 32 | export function toggleAmbientSound(): boolean { 33 | return soundConfig.ambientEnabled; 34 | } 35 | 36 | // Toggle sound effects on/off 37 | export function toggleSoundEffects(): boolean { 38 | return soundConfig.effectsEnabled; 39 | } 40 | 41 | // Set sound volume 42 | export function setSoundVolume(volume: number): void { 43 | soundConfig.volume = Math.max(0, Math.min(1, volume)); 44 | } 45 | 46 | // Initialize sound system (does nothing) 47 | export function initSoundSystem(): void { 48 | // No-op function to maintain API compatibility 49 | } -------------------------------------------------------------------------------- /src/ui/uiHelpers.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline'; 2 | import kleur from 'kleur'; 3 | import { getCurrentGameState } from '../core/gameState'; 4 | import { getPreviousCommand, getNextCommand } from './commandHistory'; 5 | 6 | // Enable colors 7 | kleur.enabled = true; 8 | 9 | export function clearScreen(): void { 10 | console.clear(); 11 | } 12 | 13 | export function promptInput(prompt: string): Promise { 14 | const rl = readline.createInterface({ 15 | input: process.stdin, 16 | output: process.stdout 17 | }); 18 | 19 | return new Promise(resolve => { 20 | rl.question(kleur.green('> ') + prompt, answer => { 21 | rl.close(); 22 | resolve(answer); 23 | }); 24 | }); 25 | } 26 | 27 | // Add styled text helpers 28 | export const styles = { 29 | title: (text: string) => kleur.bold().cyan(text), 30 | subtitle: (text: string) => kleur.bold().blue(text), 31 | success: (text: string) => kleur.bold().green(text), 32 | error: (text: string) => kleur.bold().red(text), 33 | warning: (text: string) => kleur.bold().yellow(text), 34 | info: (text: string) => kleur.bold().magenta(text), 35 | command: (text: string) => kleur.bold().yellow(text), 36 | path: (text: string) => kleur.italic().white(text), 37 | highlight: (text: string) => kleur.bold().white(text), 38 | dim: (text: string) => kleur.dim().white(text) 39 | }; 40 | 41 | // Add box drawing functions 42 | export function drawBox(title: string, content: string): string { 43 | const lines = content.split('\n'); 44 | const width = Math.max(title.length + 4, ...lines.map(line => line.length + 4)); 45 | 46 | let result = '╔' + '═'.repeat(width - 2) + '╗\n'; 47 | result += '║ ' + kleur.bold().cyan(title) + ' '.repeat(width - title.length - 3) + '║\n'; 48 | result += '╠' + '═'.repeat(width - 2) + '╣\n'; 49 | 50 | lines.forEach(line => { 51 | result += '║ ' + line + ' '.repeat(width - line.length - 3) + '║\n'; 52 | }); 53 | 54 | result += '╚' + '═'.repeat(width - 2) + '╝'; 55 | return result; 56 | } 57 | 58 | export function drawTable(headers: string[], rows: string[][]): string { 59 | // Calculate column widths 60 | const colWidths = headers.map((h, i) => 61 | Math.max(h.length, ...rows.map(row => row[i]?.length || 0)) + 2 62 | ); 63 | 64 | // Create separator line 65 | const separator = '┼' + colWidths.map(w => '─'.repeat(w)).join('┼') + '┼'; 66 | 67 | // Create header 68 | let result = '┌' + colWidths.map(w => '─'.repeat(w)).join('┬') + '┐\n'; 69 | result += '│' + headers.map((h, i) => kleur.bold().white(h.padEnd(colWidths[i]))).join('│') + '│\n'; 70 | result += '├' + separator.substring(1, separator.length - 1) + '┤\n'; 71 | 72 | // Create rows 73 | rows.forEach(row => { 74 | result += '│' + row.map((cell, i) => cell.padEnd(colWidths[i])).join('│') + '│\n'; 75 | }); 76 | 77 | // Create footer 78 | result += '└' + colWidths.map(w => '─'.repeat(w)).join('┴') + '┘'; 79 | 80 | return result; 81 | } -------------------------------------------------------------------------------- /src/ui/visualEffects.ts: -------------------------------------------------------------------------------- 1 | import figlet from 'figlet'; 2 | import gradient from 'gradient-string'; 3 | import kleur from 'kleur'; 4 | 5 | // Theme definitions 6 | export type Theme = { 7 | name: string; 8 | primary: (text: string) => string; 9 | secondary: (text: string) => string; 10 | accent: (text: string) => string; 11 | success: (text: string) => string; 12 | error: (text: string) => string; 13 | warning: (text: string) => string; 14 | info: (text: string) => string; 15 | logo: (text: string) => string; 16 | }; 17 | 18 | // Available themes 19 | export const themes = { 20 | hacker: { 21 | name: 'Hacker', 22 | primary: kleur.green, 23 | secondary: kleur.green().dim, 24 | accent: kleur.white().bold, 25 | success: kleur.green().bold, 26 | error: kleur.red().bold, 27 | warning: kleur.yellow().bold, 28 | info: kleur.blue().bold, 29 | logo: (text: string) => gradient.atlas(text) 30 | }, 31 | cyberpunk: { 32 | name: 'Cyberpunk', 33 | primary: kleur.magenta, 34 | secondary: kleur.blue, 35 | accent: kleur.yellow().bold, 36 | success: kleur.cyan().bold, 37 | error: kleur.red().bold, 38 | warning: kleur.yellow().bold, 39 | info: kleur.magenta().bold, 40 | logo: (text: string) => gradient.passion(text) 41 | }, 42 | retro: { 43 | name: 'Retro', 44 | primary: kleur.yellow, 45 | secondary: kleur.white().dim, 46 | accent: kleur.white().bold, 47 | success: kleur.green().bold, 48 | error: kleur.red().bold, 49 | warning: kleur.yellow().bold, 50 | info: kleur.blue().bold, 51 | logo: (text: string) => gradient.morning(text) 52 | } 53 | }; 54 | 55 | // Current theme (default to hacker) 56 | let currentTheme: Theme = themes.hacker; 57 | 58 | // Set the active theme 59 | export function setTheme(themeName: keyof typeof themes): void { 60 | if (themes[themeName]) { 61 | currentTheme = themes[themeName]; 62 | } 63 | } 64 | 65 | // Get the current theme 66 | export function getTheme(): Theme { 67 | return currentTheme; 68 | } 69 | 70 | // Generate figlet text 71 | export function figletText(text: string, font: figlet.Fonts = 'Standard'): string { 72 | try { 73 | return figlet.textSync(text, { font }); 74 | } catch (error) { 75 | console.error('Error generating figlet text:', error); 76 | return text; 77 | } 78 | } 79 | 80 | // Animated text display 81 | export async function animateText(text: string, delay = 30): Promise { 82 | for (let i = 0; i < text.length; i++) { 83 | process.stdout.write(currentTheme.primary(text[i])); 84 | await new Promise(resolve => setTimeout(resolve, delay)); 85 | } 86 | console.log(''); 87 | } 88 | 89 | // Loading animation 90 | export async function loadingAnimation(message: string, duration = 2000): Promise { 91 | const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; 92 | let i = 0; 93 | 94 | const start = Date.now(); 95 | const interval = setInterval(() => { 96 | const frame = frames[i++ % frames.length]; 97 | process.stdout.write(`\r${currentTheme.primary(frame)} ${message}`); 98 | }, 80); 99 | 100 | await new Promise(resolve => setTimeout(resolve, duration)); 101 | clearInterval(interval); 102 | process.stdout.write(`\r${currentTheme.success('✓')} ${message}\n`); 103 | } 104 | 105 | // Success animation 106 | export async function successAnimation(message: string): Promise { 107 | const frames = ['[ ]', '[= ]', '[== ]', '[=== ]', '[====]']; 108 | let i = 0; 109 | 110 | const interval = setInterval(() => { 111 | process.stdout.write(`\r${currentTheme.success(frames[i++ % frames.length])} ${message}`); 112 | if (i > frames.length * 2) { 113 | clearInterval(interval); 114 | console.log(`\r${currentTheme.success('[====]')} ${message}`); 115 | } 116 | }, 100); 117 | 118 | await new Promise(resolve => setTimeout(resolve, frames.length * 2 * 100 + 100)); 119 | } 120 | 121 | // Typewriter effect for terminal output 122 | export async function typewriter(text: string, speed = 10): Promise { 123 | const lines = text.split('\n'); 124 | 125 | for (const line of lines) { 126 | for (let i = 0; i < line.length; i++) { 127 | process.stdout.write(line[i]); 128 | await new Promise(resolve => setTimeout(resolve, speed)); 129 | } 130 | console.log(''); 131 | } 132 | } 133 | 134 | // Generate a cool logo 135 | export function generateLogo(): string { 136 | const logo = figletText('SHELLQUEST', 'ANSI Shadow'); 137 | return currentTheme.logo(logo); 138 | } 139 | 140 | // Boot sequence animation 141 | export async function bootSequence(): Promise { 142 | console.clear(); 143 | 144 | await loadingAnimation('Initializing system', 1000); 145 | await loadingAnimation('Loading kernel modules', 800); 146 | await loadingAnimation('Mounting file systems', 600); 147 | await loadingAnimation('Starting network services', 700); 148 | await loadingAnimation('Launching ShellQuest', 1000); 149 | 150 | console.log('\n'); 151 | console.log(generateLogo()); 152 | console.log('\n'); 153 | 154 | await animateText('Welcome to ShellQuest - A Linux Terminal Escape Room Game', 20); 155 | console.log('\n'); 156 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "outDir": "dist", 10 | "rootDir": "src", 11 | "types": ["bun-types"] 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules", "dist"] 15 | } --------------------------------------------------------------------------------