From 73fd2d67f436d99462ca06e5e8e36131f24e4044 Mon Sep 17 00:00:00 2001 From: Jonathan Hite Date: Thu, 6 Feb 2025 22:33:14 -0500 Subject: [PATCH] Added game system. [G]ames are available in the Utilities menu. The readme was updated. A gamefile syntax readme was added. --- README.md | 2 + command_handlers.py | 288 ++++++++++++++++++++++++++++++++++++++++++ example_config.ini | 102 --------------- gamefile_syntax.md | 66 ++++++++++ games/lost_forest.csv | 8 ++ games/testing.txt | 1 + games/testnoname | 0 message_processing.py | 42 +++++- server.py | 2 +- text.py | 10 ++ validate_game.py | 122 ++++++++++++++++++ 11 files changed, 537 insertions(+), 106 deletions(-) delete mode 100644 example_config.ini create mode 100644 gamefile_syntax.md create mode 100644 games/lost_forest.csv create mode 100644 games/testing.txt create mode 100644 games/testnoname create mode 100644 text.py create mode 100644 validate_game.py diff --git a/README.md b/README.md index 9f67210..a08a828 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,8 @@ The following device roles have been working: - **Statistics**: View statistics about nodes, hardware, and roles. - **Wall of Shame**: View devices with low battery levels. - **Fortune Teller**: Get a random fortune. Pulls from the fortunes.txt file. Feel free to edit this file remove or add more if you like. +- **GAMES**: GAMES!!! BBSs have to have games. You can make and add your own! Games should be written in python. The Games Menu gets the names from the GameTitle variable. (e.g. GameTitle="A Brave Adventure") in the game.py file. + ## Usage diff --git a/command_handlers.py b/command_handlers.py index 16695ba..9f9b8ce 100644 --- a/command_handlers.py +++ b/command_handlers.py @@ -2,6 +2,7 @@ import configparser import logging import random import time +import os from meshtastic import BROADCAST_NUM @@ -43,6 +44,8 @@ def build_menu(items, menu_name): menu_str += "[C]hannel Dir\n" elif item.strip() == 'J': menu_str += "[J]S8CALL\n" + elif item.strip() == 'G': + menu_str += "[G]ames\n" elif item.strip() == 'S': menu_str += "[S]tats\n" elif item.strip() == 'F': @@ -666,3 +669,288 @@ def handle_quick_help_command(sender_id, interface): response = ("✈️QUICK COMMANDS✈️\nSend command below for usage info:\nSM,, - Send " "Mail\nCM - Check Mail\nPB,, - Post Bulletin\nCB,, - Check Bulletins\n") send_message(response, sender_id, interface) + +def get_games_available(game_files): + """Returns a dictionary of available games with their filenames and titles. + + - If the first line contains `title="Game Title"`, it uses that as the display name. + - Otherwise, it uses the filename (without extension). + """ + + games = {} + + for file in game_files: + try: + file_path = os.path.join('./games', file) + with open(file_path, 'r', encoding='utf-8') as fp: + first_line = fp.readline().strip() + + # Check if the first line has a title definition + if first_line.lower().startswith("title="): + game_title = first_line.split("=", 1)[1].strip().strip('"') + else: + game_title = file # Use the filename as the title + + games[game_title] = file # Store the title with its correct filename + + except Exception as e: + print(f"Error loading game {file}: {e}") + + return games # Return a dictionary {Title: Filename} + + +def handle_games_command(sender_id, interface): + """Handles the Games Menu and lists available text-based games.""" + + # Find files in ./games that: + # - Have a .txt or .csv extension + # - OR have no extension + game_files = [ + f for f in os.listdir('./games') + if os.path.isfile(os.path.join('./games', f)) and (f.endswith('.txt') or f.endswith('.csv') or '.' not in f) + ] + + games_available = get_games_available(game_files) + + if not games_available: + send_message("No games available yet. Come back soon.", sender_id, interface) + update_user_state(sender_id, {'command': 'UTILITIES', 'step': 1}) + return None + + # Store game filenames in state to avoid title-related issues + game_titles = list(games_available.keys()) # Display titles + game_filenames = list(games_available.values()) # Actual filenames + + # Include exit option + numbered_games = "\n".join(f"{i+1}. {title}" for i, title in enumerate(game_titles)) + numbered_games += "\n[X] Exit" + + response = f"🎮 Games Menu 🎮\nWhich game would you like to play?\n{numbered_games}" + send_message(response, sender_id, interface) + + update_user_state(sender_id, {'command': 'GAMES', 'step': 1, 'games': game_filenames, 'titles': game_titles}) + + return response + + + + +def handle_game_menu_selection(sender_id, message, step, interface, state): + """Handles the user's selection of a game from the Games Menu, allowing exit with 'X' and starting immediately.""" + + # Allow users to exit with "X" like other menus + if message.lower() == "x": + handle_help_command(sender_id, interface) # Return to main menu + return + + games_available = state.get('games', []) + + try: + game_index = int(message) - 1 # Convert user input to zero-based index + if 0 <= game_index < len(games_available): + selected_game = games_available[game_index] + + # Update state to indicate the user is now in-game + update_user_state(sender_id, {'command': 'IN_GAME', 'step': 3, 'game': selected_game}) + + # Start the game immediately + start_selected_game(sender_id, interface, {'game': selected_game}) + else: + send_message("Invalid selection. Please enter a valid game number or 'X' to exit.", sender_id, interface) + + except ValueError: + send_message("Invalid input. Please enter a number corresponding to a game or 'X' to exit.", sender_id, interface) + +def start_selected_game(sender_id, interface, state): + """Starts the game selected by the user and ensures title detection.""" + + game_name = state.get('game', None) + if not game_name: + send_message("Unexpected error: No game found. Returning to game menu.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + # Construct the game file path + game_file_path = os.path.join('./games', game_name) + + # Final check if the file exists + if not os.path.exists(game_file_path): + send_message(f"Error: The game '{game_name}' could not be loaded.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + # Load the game map with title handling + try: + game_title, game_map = load_game_map(game_file_path) + except Exception as e: + send_message(f"Error loading game: {e}", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + if not game_map: + send_message(f"Error: The game '{game_name}' could not be loaded.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + # Set up the user state for playing (ENSURE game_title is included) + new_state = { + 'command': 'IN_GAME', + 'step': 3, + 'game': game_name, + 'game_title': game_title, # ✅ Ensure title is stored + 'game_map': game_map, + 'game_position': 1 + } + update_user_state(sender_id, new_state) + + # Present the first segment + present_story_segment(sender_id, interface, new_state) # ✅ Pass updated state + + +def load_game_map(file_path): + """Loads a game map from a CSV file and returns its structured format.""" + + print(f"DEBUG: Inside load_game_map(), trying to open {file_path}") + + try: + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + print(f"DEBUG: Read {len(lines)} lines from file.") + + if not lines: + print("❌ ERROR: File is empty!") + return None + + # Check if the first line contains a title + first_line = lines[0].strip() + if first_line.lower().startswith("title="): + game_title = first_line.split("=", 1)[1].strip().strip('"') + game_lines = lines[1:] # Skip title + else: + game_title = file_path # Use filename if no title + game_lines = lines + + print(f"DEBUG: Game title detected -> {game_title}") + + # Parse game map + game_map = {} + for i, line in enumerate(game_lines, start=1): + game_map[i] = line.strip().split(",") + + print(f"DEBUG: Successfully loaded game map with {len(game_map)} entries.") + return game_title, game_map + + except Exception as e: + print(f"❌ ERROR inside load_game_map(): {e}") + return None + + +def present_story_segment(sender_id, interface, state): + """Presents the current segment of the game and available choices.""" + + game_name = state.get('game') + game_title = state.get('game_title', "Unknown Game") # ✅ Prevent KeyError + game_map = state.get('game_map', {}) + game_position = state.get('game_position', 1) + + if game_position not in game_map: + send_message("Error: Invalid game state.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + # Retrieve the current story segment + segment = game_map[game_position] + storyline = segment[0] + choices = segment[1:] + + # Build response message + response = f"🎮 {game_title} 🎮\n\n{storyline}\n\n" + for i in range(0, len(choices), 2): # Display numbered choices + response += f"{(i//2)+1}. {choices[i]}\n" + + response += "\n[X] Exit" + + send_message(response, sender_id, interface) + + # Update user state to track the current game progress + update_user_state(sender_id, { + 'command': 'IN_GAME', + 'step': 3, + 'game': game_name, + 'game_title': game_title, # ✅ Ensure it stays in state + 'game_map': game_map, + 'game_position': game_position + }) + +def process_game_choice(sender_id, message, interface, state): + """Processes the player's choice and advances the game.""" + + game_map = state.get('game_map', {}) + game_position = state.get('game_position', 1) + + if game_position not in game_map: + send_message("Error: Invalid game state.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + segment = game_map[game_position] + + # Extract the storyline and choices + storyline = segment[0] # First element is the story text + choices = segment[1:] # Remaining elements are choices + + # Ensure choices are properly formatted (must be in pairs) + if len(choices) % 2 != 0: + send_message("Error: Game data is corrupted.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + # Handle Exit + if message.lower() == "x": + send_message(f"Exiting '{state['game_title']}'... Returning to Games Menu.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + handle_games_command(sender_id, interface) # Immediately display the game menu + return + + try: + # Convert user input to index (1-based to 0-based) + choice_index = int(message) - 1 + + # Validate choice selection + if choice_index < 0 or choice_index * 2 + 1 >= len(choices): + send_message("Invalid selection. Please enter a valid number.", sender_id, interface) + return + + # Retrieve the target position for the chosen option + target_position = int(choices[choice_index * 2 + 1]) + + # Check if the target position exists + if target_position not in game_map: + send_message("💀 Game Over! You fell into an abyss. 💀", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + handle_games_command(sender_id, interface) # Return to game menu + return + + # Update state with the new game position + update_user_state(sender_id, { + 'command': 'IN_GAME', + 'step': 3, + 'game': state['game'], + 'game_title': state['game_title'], + 'game_map': game_map, + 'game_position': target_position + }) + + # Present the new story segment + present_story_segment(sender_id, interface, { + 'command': 'IN_GAME', + 'step': 3, + 'game': state['game'], + 'game_title': state['game_title'], + 'game_map': game_map, + 'game_position': target_position + }) + + except (ValueError, IndexError): + send_message("Invalid selection. Please enter a valid number.", sender_id, interface) diff --git a/example_config.ini b/example_config.ini deleted file mode 100644 index 3066b36..0000000 --- a/example_config.ini +++ /dev/null @@ -1,102 +0,0 @@ -############################### -#### Select Interface type #### -############################### -# [type = serial] for USB connected devices -#If there are multiple serial devices connected, be sure to use the "port" option and specify a port -# Linux Example: -# port = /dev/ttyUSB0 -# -# Windows Example: -# port = COM3 -# [type = tcp] for network connected devices (ESP32 devices only - this does not work for WisBlock) -# If using tcp, remove the # from the beginning and replace 192.168.x.x with the IP address of your device -# Example: -# [interface] -# type = tcp -# hostname = 192.168.1.100 - -[interface] -type = serial -# port = /dev/ttyACM0 -# hostname = 192.168.x.x - - -############################ -#### BBS NODE SYNC LIST #### -############################ -# Provide a list of other nodes running TC²-BBS to sync mail messages and bulletins with -# Enter in a list of other BBS Nodes by their nodeID separated by commas (no spaces) -# Example: -# [sync] -# bbs_nodes = !17d7e4b7,!18e9f5a3,!1a2b3c4d - -# [sync] -# bbs_nodes = !17d7e4b7 - - -############################ -#### Allowed Node IDs #### -############################ -# Provide a list of node IDs that are allowed to post to the urgent board. -# If this section is commented out, anyone can post to the urgent board. -# Example: -# [allow_list] -# allowed_nodes = 12345678,87654321 -# -# [allow_list] -# allowed_nodes = !17d7e4b7 - - -#################### -#### Menu Items #### -#################### -# Remove any menu items you don't plan on using with your BBS below -# -[menu] -# Default Main Menu options for reference -# [Q]uick Commands -# [B]BS -# [U]tilities -# E[X]IT -# -# Remove any menu items from the list below that you want to exclude from the main menu -main_menu_items = Q, B, U, X - -# Default BBS Menu options for reference -# [M]ail -# [B]ulletins -# [C]hannel Dir -# [J]S8CALL -# E[X]IT -# -# Remove any menu items from the list below that you want to exclude from the BBS menu -bbs_menu_items = M, B, C, J, X - -# Default Utilities Menu option for reference -# [S]tats -# [F]ortune -# [W]all of Shame -# E[X]IT -# -# Remove any menu items from the list below that you want to exclude from the utilities menu -utilities_menu_items = S, F, W, X - - -########################## -#### JS8Call Settings #### -########################## -# If you would like messages from JS8Call to go into the BBS, uncomment and enter in info below: -# host = the IP address for your system running JS8Call -# port = TCP API port for JS8CALL - Default is 2442 -# db_file = this can be left as the default "js8call.db" unless you need to change for some reason -# js8groups = the JS8Call groups you're interested in receiving into the BBS -# store_messages = "true" will send messages that arent part of a group into the BBS (can be noisy). "false" will ignore these -# js8urgent = the JS8Call groups you consider to be urgent - anything sent to these will have a notice sent to the -# group chat (similar to how the urgent bulletin board works -# [js8call] -# host = 192.168.1.100 -# port = 2442 -# db_file = js8call.db -# js8groups = @GRP1,@GRP2,@GRP3 -# store_messages = True -# js8urgent = @URGNT diff --git a/gamefile_syntax.md b/gamefile_syntax.md new file mode 100644 index 0000000..d087a57 --- /dev/null +++ b/gamefile_syntax.md @@ -0,0 +1,66 @@ +# 📜 How to Format Game CSV Files + +This guide explains how to structure game files for the **text-based game system** using **CSV format**. + +--- + +## 📌 **Basic Structure** +Each line in the CSV file represents a **story segment** with: +- **Storyline text** (always first column) +- **Choices** (paired as `Choice Text, Target Line`) +- If no choices exist, it signals **GAME OVER** + +### **Example Format:** +```csv +What is your first choice?, East, 2, West, 3, North, 4, South, 5 +You chose East. A dense forest appears., Run forward, 6, Look around, 7 +You chose West. A river blocks your path., Try to swim, 8, Walk along the bank, 9 +You chose North. The path is blocked by rocks. +You chose South. A cave entrance looms ahead., Enter the cave, 10, Turn back, 11 +You went East and chose to run. The ground gives way and you fall. GAME OVER. +You went East and looked around. You see a hidden path., Follow it, 12, Stay put, 13 +``` + +--- + +## 📌 **Title Handling** +The **first line** of the file may contain a title in the format: +```csv +title="The Mysterious Forest" +``` +- If present, the game uses this as the **title**. +- If absent, the **filename** is used as the title. +- The second line will then be the **first story segment**. + +--- + +## 📌 **Rules for Formatting** +✅ **Each row starts with story text** +✅ **Choices appear as pairs** (`Choice Text, Target Line`) +✅ **Target Line must reference an existing line number** +✅ **A line without choices = GAME OVER** +✅ **Title (optional) must be in the first line as `title="Game Name"`** +✅ **File should be saved as `.csv` with UTF-8 encoding** + +--- + +## 📌 **Understanding the Flow** +1️⃣ The game **starts at line 1**. +2️⃣ The user selects a **numbered choice**. +3️⃣ The game jumps to the **corresponding line number**. +4️⃣ The game continues until: + - **No choices remain (GAME OVER)** + - **The player exits** (`X` command). + +--- + +## 📌 **Edge Cases & Notes** +⚠ **If an invalid line number is referenced**, the game may crash. +⚠ **A story segment must always be in column 1**, even if no choices follow. +⚠ **Extra spaces around text are automatically trimmed.** + +--- + +## 📌 **Final Notes** +By following this format, you can create **interactive, branching text adventures** without modifying code! 🎮🚀 + diff --git a/games/lost_forest.csv b/games/lost_forest.csv new file mode 100644 index 0000000..1665081 --- /dev/null +++ b/games/lost_forest.csv @@ -0,0 +1,8 @@ +title="The Lost Forest" +What is your first choice?, East, 2, West, 3, North, 4, South, 5 +You chose East. A dense forest appears., Run forward, 6, Look around, 7 +You chose West. A river blocks your path., Try to swim, 8, Walk along the bank, 9 +You chose North. The path is blocked by rocks. +You chose South. A cave entrance looms ahead., Enter the cave, 10, Turn back, 11 +You went East and chose to run. The ground gives way and you fall. GAME OVER. +You went East and looked around. You see a hidden path., Follow it, 12, Stay put, 13 diff --git a/games/testing.txt b/games/testing.txt new file mode 100644 index 0000000..ec6403e --- /dev/null +++ b/games/testing.txt @@ -0,0 +1 @@ +title="Test Game 9000" diff --git a/games/testnoname b/games/testnoname new file mode 100644 index 0000000..e69de29 diff --git a/message_processing.py b/message_processing.py index 80ece84..7288806 100644 --- a/message_processing.py +++ b/message_processing.py @@ -1,4 +1,5 @@ import logging +import os from meshtastic import BROADCAST_NUM @@ -8,11 +9,17 @@ from command_handlers import ( handle_channel_directory_command, handle_channel_directory_steps, handle_send_mail_command, handle_read_mail_command, handle_check_mail_command, handle_delete_mail_confirmation, handle_post_bulletin_command, handle_check_bulletin_command, handle_read_bulletin_command, handle_read_channel_command, - handle_post_channel_command, handle_list_channels_command, handle_quick_help_command -) + handle_post_channel_command, handle_list_channels_command, handle_quick_help_command, handle_games_command, process_game_choice, + start_selected_game, handle_game_menu_selection + ) + +import command_handlers + +games_available = command_handlers.get_games_available(os.listdir('./games')) + from db_operations import add_bulletin, add_mail, delete_bulletin, delete_mail, get_db_connection, add_channel from js8call_integration import handle_js8call_command, handle_js8call_steps, handle_group_message_selection -from utils import get_user_state, get_node_short_name, get_node_id_from_num, send_message +from utils import get_user_state, get_node_short_name, get_node_id_from_num, send_message, update_user_state main_menu_handlers = { "q": handle_quick_help_command, @@ -31,6 +38,7 @@ bbs_menu_handlers = { utilities_menu_handlers = { + "g": handle_games_command, "s": handle_stats_command, "f": handle_fortune_command, "w": handle_wall_of_shame_command, @@ -53,6 +61,12 @@ board_action_handlers = { "x": handle_help_command } +games_menu_handlers = { + "x": handle_help_command, +} +for i in range(1, len(games_available) + 1): + games_menu_handlers[str(i)] = lambda sender_id, interface, i=i: handle_game_menu_selection(sender_id, str(i), 1, interface, None) + def process_message(sender_id, message, interface, is_sync_message=False): state = get_user_state(sender_id) message_lower = message.lower().strip() @@ -90,6 +104,21 @@ def process_message(sender_id, message, interface, is_sync_message=False): channel_name, channel_url = parts[1], parts[2] add_channel(channel_name, channel_url) else: + # ✅ **Corrected IN_GAME Handling** + if state and state['command'] == 'IN_GAME': + message_lower = message.lower().strip() + + # Always check if the user wants to exit + if message_lower == "x": + send_message(f"Exiting '{state['game']}'... Returning to Games Menu.", sender_id, interface) + update_user_state(sender_id, {'command': 'GAMES', 'step': 1}) + return + + # Otherwise, process the game choice + process_game_choice(sender_id, message, interface, state) + return + + # 📌 Other menu processing remains unchanged if message_lower.startswith("sm,,"): handle_send_mail_command(sender_id, message_strip, interface, bbs_nodes) elif message_lower.startswith("cm"): @@ -109,6 +138,8 @@ def process_message(sender_id, message, interface, is_sync_message=False): handlers = bbs_menu_handlers elif menu_name == 'utilities': handlers = utilities_menu_handlers + elif menu_name == 'games': + handlers = games_menu_handlers else: handlers = main_menu_handlers elif state and state['command'] == 'BULLETIN_MENU': @@ -121,6 +152,9 @@ def process_message(sender_id, message, interface, is_sync_message=False): elif state and state['command'] == 'GROUP_MESSAGES': handle_group_message_selection(sender_id, message, state['step'], state, interface) return + elif state and state['command'] == 'GAMES': + handle_game_menu_selection(sender_id, message, state['step'], interface, state) + return else: handlers = main_menu_handlers @@ -176,6 +210,8 @@ def process_message(sender_id, message, interface, is_sync_message=False): handle_help_command(sender_id, interface) + + def on_receive(packet, interface): try: if 'decoded' in packet and packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP': diff --git a/server.py b/server.py index 177f82f..444d23d 100644 --- a/server.py +++ b/server.py @@ -45,7 +45,7 @@ def display_banner(): ██║ ██║ ██╔═══╝ ╚════╝██╔══██╗██╔══██╗╚════██║ ██║ ╚██████╗███████╗ ██████╔╝██████╔╝███████║ ╚═╝ ╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚══════╝ -Meshtastic Version +Meshtastic Version WITH GAMES """ print(banner) diff --git a/text.py b/text.py new file mode 100644 index 0000000..f992c61 --- /dev/null +++ b/text.py @@ -0,0 +1,10 @@ +import os + +print("Current Working Directory:", os.getcwd()) +file_path = "./games/lost_forest.csv" + +if os.path.exists(file_path): + print("✅ File exists! Python can detect it.") +else: + print("❌ File does NOT exist according to Python.") + diff --git a/validate_game.py b/validate_game.py new file mode 100644 index 0000000..857dd1f --- /dev/null +++ b/validate_game.py @@ -0,0 +1,122 @@ +import os + +def list_game_files(): + """Lists all game files in the ./games directory.""" + game_dir = "./games" + if not os.path.exists(game_dir): + print("❌ ERROR: 'games' directory does not exist.") + return [] + + game_files = [f for f in os.listdir(game_dir) if os.path.isfile(os.path.join(game_dir, f))] + + if not game_files: + print("❌ ERROR: No game files found in the './games' directory.") + return [] + + return game_files + + +def validate_game_file(file_path): + """Validates the format of a game CSV file.""" + + if not os.path.exists(file_path): + print(f"❌ ERROR: File '{file_path}' does not exist.") + return False + + with open(file_path, "r", encoding="utf-8") as file: + lines = file.readlines() + + if not lines: + print(f"❌ ERROR: File '{file_path}' is empty.") + return False + + # Check title format + first_line = lines[0].strip() + if first_line.lower().startswith("title="): + title = first_line.split("=", 1)[1].strip().strip('"') + print(f"✅ Title detected: {title}") + game_lines = lines[1:] # Skip title line + else: + print(f"⚠️ WARNING: No title detected. Using filename instead.") + game_lines = lines + + game_map = {} + valid_lines = set() + + for index, line in enumerate(game_lines, start=1): + parts = [p.strip() for p in line.strip().split(",")] + + if not parts or len(parts) < 1: + print(f"❌ ERROR: Line {index} is empty or improperly formatted.") + return False + + # First element is the story text + storyline = parts[0] + choices = parts[1:] + + # Validate choice pairs + if len(choices) % 2 != 0: + print(f"❌ ERROR: Line {index} has an uneven number of choices. Choices must be in pairs.") + return False + + # Validate choices mapping + for i in range(1, len(choices), 2): + choice_text = choices[i - 1] + try: + target_line = int(choices[i]) + valid_lines.add(target_line) + except ValueError: + print(f"❌ ERROR: Invalid mapping in line {index} ('{choice_text}' does not map to a valid number).") + return False + + # Store story segment + game_map[index] = (storyline, choices) + + # Validate that all mapped lines exist + missing_lines = valid_lines - set(game_map.keys()) + if missing_lines: + print(f"❌ ERROR: The following mapped lines do not exist: {sorted(missing_lines)}") + return False + + print(f"✅ Validation passed for '{file_path}'. No errors detected!") + return True + + +def main(): + """Lists games and asks user which to validate.""" + game_files = list_game_files() + + if not game_files: + return + + print("\nAvailable games for validation:") + for i, game in enumerate(game_files, start=1): + print(f"{i}. {game}") + print("A. Validate ALL games") + print("X. Exit") + + choice = input("\nSelect a game to validate (or 'A' for all, 'X' to exit): ").strip().lower() + + if choice == "x": + print("Exiting...") + return + elif choice == "a": + print("\n🔍 Validating all games...") + for game in game_files: + print(f"\n🔎 Validating {game}...") + validate_game_file(os.path.join("./games", game)) + else: + try: + game_index = int(choice) - 1 + if 0 <= game_index < len(game_files): + game_path = os.path.join("./games", game_files[game_index]) + print(f"\n🔎 Validating {game_files[game_index]}...") + validate_game_file(game_path) + else: + print("❌ ERROR: Invalid selection.") + except ValueError: + print("❌ ERROR: Invalid input. Please enter a number or 'A'/'X'.") + +if __name__ == "__main__": + main() +