shellquest/LEVELS.md
2025-04-01 12:15:40 +02:00

16 KiB

LEVELS.md - Guide to Adding New Levels

Introduction

ShellQuest is designed to be easily expandable with new levels. This guide explains how to create and integrate new levels into the game.

Level Structure

Each level is defined as an object that implements the Level interface. A level consists of:

  1. Basic Information: ID, name, and description
  2. State Management: Methods to initialize and manage level-specific state
  3. User Interface: Methods to render the level and handle user input
  4. Hints: An array of progressive hints for players who get stuck

Creating a New Level

Step 1: Create a new file

Create a new file in the src/levels directory, e.g., levelX.ts where X is the next level number.

Step 2: Implement the Level interface

import { Level, LevelResult, registerLevel } from '../core/levelSystem';
import { getCurrentGameState } from '../core/gameState';
import { levelUI } from '../ui/levelRenderer'; // Optional, for enhanced UI

const level: Level = {
  id: X, // Replace X with the next level number
  name: 'Your Level Name',
  description: 'Brief description of your level',

  async initialize() {
    const gameState = getCurrentGameState();
    if (!gameState) return;

    // Initialize level state if not already present
    if (!gameState.levelStates[this.id]) {
      gameState.levelStates[this.id] = {
        // Define your level-specific state here
        // For example:
        attempts: 0,
        someFlag: false,
        // Add any other state variables your level needs
      };
    }
  },

  async render() {
    const gameState = getCurrentGameState();
    if (!gameState) return;

    // Make sure level state is initialized
    if (!gameState.levelStates[this.id]) {
      await this.initialize();
    }

    const levelState = gameState.levelStates[this.id];

    // Display level information and UI
    console.log('Your level description and UI goes here');
    console.log('');

    // Display available commands
    console.log('Commands: "command1", "command2", etc.');
  },

  async handleInput(input: string): Promise<LevelResult> {
    const gameState = getCurrentGameState();
    if (!gameState) {
      return { completed: false };
    }

    // Make sure level state is initialized
    if (!gameState.levelStates[this.id]) {
      await this.initialize();
    }

    const levelState = gameState.levelStates[this.id];
    const command = input.trim();

    // Split command into parts
    const parts = command.split(' ');
    const cmd = parts[0].toLowerCase();

    // Handle different commands
    if (cmd === 'command1') {
      // Do something
      return {
        completed: false,
        message: 'Response to command1'
      };
    }

    // Handle level completion
    if (cmd === 'win_command') {
      return {
        completed: true,
        message: 'Congratulations! You completed the level.',
        nextAction: 'next_level'
      };
    }

    // Default response for unknown commands
    return {
      completed: false,
      message: 'Unknown command. Try something else.'
    };
  },

  hints: [
    'First hint - very subtle',
    'Second hint - more direct',
    'Third hint - almost gives away the solution'
  ]
};

export function registerLevelX() { // Replace X with the level number
  registerLevel(level);
}

Step 3: Update the levels index file

Edit src/levels/index.ts to import and register your new level:

import { registerLevel1 } from "./level1";
import { registerLevel2 } from "./level2";
// ... other levels
import { registerLevelX } from "./levelX"; // Add your new level

export function registerAllLevels() {
  registerLevel1();
  registerLevel2();
  // ... other levels
  registerLevelX(); // Register your new level

  console.log("All levels registered successfully.");
}

Implementing Complex Level Mechanics

File System Navigation

To implement a file system level (like Level 2), you need to:

  1. Create a virtual file system structure in your level state
  2. Implement commands like ls, cd, and cat
  3. Track the current directory and handle path navigation

Here's an example of a file system state structure:

// In initialize()
gameState.levelStates[this.id] = {
  currentDir: "/home/user",
  fileSystem: {
    "/home/user": {
      type: "dir",
      contents: ["Documents", "Pictures", ".hidden"],
    },
    "/home/user/Documents": {
      type: "dir",
      contents: ["notes.txt"],
    },
    "/home/user/Documents/notes.txt": {
      type: "file",
      content: "This is a text file.",
    },
    // Add more directories and files
  },
};

Handling file system commands:

// In handleInput()
if (cmd === "ls") {
  // List directory contents
  return {
    completed: false,
    message: fileSystem[levelState.currentDir].contents.join("\n"),
  };
}

if (cmd === "cd" && parts.length > 1) {
  const target = parts[1];

  if (target === "..") {
    // Go up one directory
    const pathParts = levelState.currentDir.split("/");
    if (pathParts.length > 2) {
      pathParts.pop();
      levelState.currentDir = pathParts.join("/");
      return {
        completed: false,
        message: `Changed directory to ${levelState.currentDir}`,
      };
    }
  } else {
    // Go to specified directory
    const newPath = `${levelState.currentDir}/${target}`;

    if (fileSystem[newPath] && fileSystem[newPath].type === "dir") {
      levelState.currentDir = newPath;
      return {
        completed: false,
        message: `Changed directory to ${levelState.currentDir}`,
      };
    }
  }
}

if (cmd === "cat" && parts.length > 1) {
  const target = parts[1];
  const path = `${levelState.currentDir}/${target}`;

  if (fileSystem[path] && fileSystem[path].type === "file") {
    return {
      completed: false,
      message: fileSystem[path].content,
    };
  }
}

Process Management

For a process management level (like Level 3), you can:

  1. Create a list of processes with properties like PID, name, CPU usage, etc.
  2. Implement commands like ps, kill, and start
  3. Track process states and check for completion conditions

Example process state:

// In initialize()
gameState.levelStates[this.id] = {
  processes: [
    { pid: 1, name: "systemd", cpu: 0.1, memory: 4.2, status: "running" },
    { pid: 423, name: "sshd", cpu: 0.0, memory: 1.1, status: "running" },
    {
      pid: 842,
      name: "malware.bin",
      cpu: 99.7,
      memory: 85.5,
      status: "running",
    },
    { pid: 1024, name: "firewall", cpu: 0.1, memory: 1.8, status: "stopped" },
  ],
  firewallStarted: false,
  malwareKilled: false,
};

Handling process commands:

// In handleInput()
if (cmd === "ps") {
  // Format and display process list
  let output = "PID    NAME         CPU%    MEM%    STATUS\n";
  output += "--------------------------------------------\n";

  levelState.processes.forEach((proc) => {
    output += `${proc.pid.toString().padEnd(7)}${proc.name.padEnd(13)}${proc.cpu
      .toFixed(1)
      .padEnd(8)}${proc.memory.toFixed(1).padEnd(8)}${proc.status}\n`;
  });

  return {
    completed: false,
    message: output,
  };
}

if (cmd === "kill" && parts.length > 1) {
  const pid = parseInt(parts[1]);
  const process = levelState.processes.find((p) => p.pid === pid);

  if (process) {
    process.status = "stopped";

    if (process.name === "malware.bin") {
      levelState.malwareKilled = true;

      // Check if level is completed
      if (levelState.firewallStarted) {
        return {
          completed: true,
          message: "System secured! Malware stopped and firewall running.",
          nextAction: "next_level",
        };
      }
    }
  }
}

if (cmd === "start" && parts.length > 1) {
  const pid = parseInt(parts[1]);
  const process = levelState.processes.find((p) => p.pid === pid);

  if (process) {
    process.status = "running";

    if (process.name === "firewall") {
      levelState.firewallStarted = true;

      // Check if level is completed
      if (levelState.malwareKilled) {
        return {
          completed: true,
          message: "System secured! Malware stopped and firewall running.",
          nextAction: "next_level",
        };
      }
    }
  }
}

File Permissions

For a permissions-based level (like Level 4):

  1. Create files with permission attributes
  2. Implement commands like chmod and sudo
  3. Check permissions before allowing file access

Example permissions state:

// In initialize()
gameState.levelStates[this.id] = {
  files: [
    {
      name: "README.txt",
      permissions: "rw-r--r--",
      owner: "user",
      group: "user",
    },
    {
      name: "secret_data.db",
      permissions: "----------",
      owner: "root",
      group: "root",
    },
    {
      name: "change_permissions.sh",
      permissions: "r--------",
      owner: "user",
      group: "user",
    },
  ],
  currentUser: "user",
  sudoAvailable: false,
};

Handling permission commands:

// In handleInput()
if (cmd === "cat" && parts.length > 1) {
  const fileName = parts[1];
  const file = levelState.files.find((f) => f.name === fileName);

  if (file) {
    // Check if user has read permission
    const canRead =
      (levelState.currentUser === file.owner && file.permissions[0] === "r") ||
      (levelState.currentUser !== file.owner &&
        levelState.currentUser === file.group &&
        file.permissions[3] === "r") ||
      (levelState.currentUser !== file.owner &&
        levelState.currentUser !== file.group &&
        file.permissions[6] === "r") ||
      levelState.currentUser === "root"; // root can read anything

    if (!canRead) {
      return {
        completed: false,
        message: `Permission denied: Cannot read ${fileName}`,
      };
    }

    // Return file content based on the file name
    if (fileName === "README.txt") {
      return {
        completed: false,
        message:
          "This system contains important data. You need to access secret_data.db to proceed.",
      };
    }
    // Handle other files...
  }
}

if (cmd === "chmod" && parts.length > 2) {
  const permissions = parts[1]; // e.g., +x
  const fileName = parts[2];
  const file = levelState.files.find((f) => f.name === fileName);

  if (file) {
    // Check if user owns the file
    if (
      levelState.currentUser !== file.owner &&
      levelState.currentUser !== "root"
    ) {
      return {
        completed: false,
        message: `Permission denied: Only the owner can change permissions of ${fileName}`,
      };
    }

    // Handle different chmod formats
    if (permissions === "+x") {
      // Make file executable
      let newPermissions = file.permissions.split("");
      newPermissions[2] = "x"; // Owner execute
      file.permissions = newPermissions.join("");

      if (fileName === "change_permissions.sh") {
        levelState.scriptExecutable = true;
      }

      return {
        completed: false,
        message: `Changed permissions of ${fileName} to ${file.permissions}`,
      };
    }
    // Handle other permission changes...
  }
}

Network Configuration

For a network-based level (like Level 5):

  1. Create network interfaces, firewall rules, and DNS settings
  2. Implement commands like ifconfig, ping, and firewall-cmd
  3. Track network state and check for connectivity

Example network state:

// In initialize()
gameState.levelStates[this.id] = {
  interfaces: [
    { name: "lo", status: "UP", ip: "127.0.0.1", netmask: "255.0.0.0" },
    { name: "eth0", status: "DOWN", ip: "", netmask: "" },
  ],
  firewall: {
    enabled: true,
    rules: [
      { port: 80, protocol: "tcp", action: "DENY" },
      { port: 443, protocol: "tcp", action: "DENY" },
    ],
  },
  dns: {
    configured: false,
    server: "",
  },
  gateway: {
    configured: false,
    address: "",
  },
};

Handling network commands:

// In handleInput()
if (cmd === "ifconfig") {
  if (parts.length === 1) {
    // Show all interfaces
    let output = "Network Interfaces:\n";
    output += "NAME   STATUS   IP            NETMASK\n";
    output += "----------------------------------------\n";

    levelState.interfaces.forEach((iface) => {
      output += `${iface.name.padEnd(7)}${iface.status.padEnd(
        9
      )}${iface.ip.padEnd(14)}${iface.netmask}\n`;
    });

    return {
      completed: false,
      message: output,
    };
  } else if (parts.length >= 4) {
    // Configure an interface
    const ifaceName = parts[1];
    const ip = parts[2];
    const netmask = parts[3];

    const iface = levelState.interfaces.find((i) => i.name === ifaceName);

    if (iface) {
      iface.ip = ip;
      iface.netmask = netmask;

      return {
        completed: false,
        message: `Configured ${ifaceName} with IP ${ip} and netmask ${netmask}.`,
      };
    }
  }
}

if (cmd === "ifup" && parts.length > 1) {
  const ifaceName = parts[1];
  const iface = levelState.interfaces.find((i) => i.name === ifaceName);

  if (iface) {
    iface.status = "UP";

    return {
      completed: false,
      message: `Interface ${ifaceName} is now UP.`,
    };
  }
}

if (cmd === "firewall-cmd") {
  if (parts.includes("--disable")) {
    levelState.firewall.enabled = false;

    return {
      completed: false,
      message: "Firewall disabled.",
    };
  }

  if (parts.includes("--allow") && parts.length > 2) {
    const port = parseInt(parts[parts.indexOf("--allow") + 1]);

    // Find the rule for this port
    const rule = levelState.firewall.rules.find((r) => r.port === port);

    if (rule) {
      rule.action = "ALLOW";

      return {
        completed: false,
        message: `Port ${port} is now allowed through the firewall.`,
      };
    }
  }
}

Using the Enhanced UI

The game includes a levelUI helper in src/ui/levelRenderer.ts that provides styled UI components for your levels:

import { levelUI } from "../ui/levelRenderer";

// In render()
levelUI.title("Welcome to My Level");
levelUI.paragraph("This is a description of the level.");
levelUI.spacer();

// Display a terminal
levelUI.terminal(
  "$ ls -la\ntotal 12\ndrwxr-xr-x 2 user user 4096 Jan 1 12:00 ."
);

// Display a file system
const items = [
  { name: "Documents", type: "dir" },
  { name: "notes.txt", type: "file" },
];
levelUI.fileSystem("/home/user", items);

// Display a process table
levelUI.processTable(levelState.processes);

// Display available commands
levelUI.commands(["ls", "cd [dir]", "cat [file]"]);

Level State Management

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.

Level Results

When handling user input, your level should return a LevelResult object:

interface LevelResult {
  completed: boolean; // Whether the level is completed
  message?: string; // Optional message to display to the user
  nextAction?: "next_level" | "main_menu" | "continue"; // What to do next
}
  • Set completed: true when the player solves the level
  • Use nextAction: 'next_level' to proceed to the next level
  • Use nextAction: 'main_menu' to return to the main menu
  • Use nextAction: 'continue' or omit to stay in the current level

Best Practices

  1. Initialization: Always check if the level state exists and initialize it if not
  2. Progressive Difficulty: Make your level challenging but fair
  3. Clear Instructions: Make sure players understand what they need to do
  4. Meaningful Feedback: Provide helpful responses to player commands
  5. Multiple Solutions: When possible, allow multiple ways to solve the puzzle
  6. Thematic Consistency: Try to maintain the Linux/tech theme of the game
  7. Hints: Provide at least 3 hints of increasing specificity

Example Level Ideas

  • Network Security: Configure a firewall to block specific attacks
  • Cryptography: Decode encrypted messages using various ciphers
  • Database Challenge: Use SQL-like commands to extract information
  • Git Simulation: Navigate and manipulate a git repository
  • Container Escape: Escape from a simulated container environment

Testing Your Level

Always test your level thoroughly to ensure:

  • It can be completed
  • All commands work as expected
  • The level state initializes correctly
  • Transitions to the next level work properly

Happy level creating!