Added game system. [G]ames are available in the Utilities menu. The readme was updated. A gamefile syntax readme was added.

This commit is contained in:
Jonathan Hite 2025-02-06 22:33:14 -05:00
parent 27279577dd
commit 73fd2d67f4
11 changed files with 537 additions and 106 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

66
gamefile_syntax.md Normal file
View File

@ -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! 🎮🚀

8
games/lost_forest.csv Normal file
View File

@ -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
Can't render this file because it contains an unexpected character in line 1 and column 7.

1
games/testing.txt Normal file
View File

@ -0,0 +1 @@
title="Test Game 9000"

0
games/testnoname Normal file
View File

View File

@ -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':

View File

@ -45,7 +45,7 @@ def display_banner():
Meshtastic Version
Meshtastic Version WITH GAMES
"""
print(banner)

10
text.py Normal file
View File

@ -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.")

122
validate_game.py Normal file
View File

@ -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()