Skip to content

Games! Sorta. #112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ venv/
.idea
config.ini
fortunes.txt
offline_game_tester.py
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 a CSV format. The Games Menu gets the names from the title variable. (e.g. title="A Brave Adventure") in the game file. See gamefile_syntax.md for more information.


## Usage

Expand Down
284 changes: 284 additions & 0 deletions command_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import random
import time
import os

from meshtastic import BROADCAST_NUM

Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -666,3 +669,284 @@ 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."""

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

game_titles = list(games_available.keys()) # Display titles
game_filenames = list(games_available.values()) # Actual filenames

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)

# ✅ Ensure `games` state is always reset when displaying the menu
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]

# Reset user state to ensure a clean start
update_user_state(sender_id, None)

# 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 = os.path.splitext(os.path.basename(file_path))[0] # Use filename without path/extension
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")
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})
handle_games_command(sender_id, interface)
return

# Retrieve the current story segment
segment = game_map[game_position]
storyline = segment[0]
choices = segment[1:] # Extract choices

# 🛠️ **Check if this is a game-over state (no choices)**
if not choices:
send_message(f"🎮 {game_title} 🎮\n\n{storyline}\n\n💀 GAME OVER! Returning to the game menu...", sender_id, interface)

# Reset user state before returning to menu
update_user_state(sender_id, {'command': 'GAMES', 'step': 1})

import time
time.sleep(1) # Ensure the message is processed before switching menus

handle_games_command(sender_id, interface)
return

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

# Ensure user state is properly tracked
update_user_state(sender_id, {
'command': 'IN_GAME',
'step': 3,
'game': game_name,
'game_title': game_title,
'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})
handle_games_command(sender_id, interface)
return

segment = game_map[game_position]

# Extract the storyline and choices
storyline = segment[0]
choices = segment[1:]

# Ensure choices are properly formatted
if len(choices) % 2 != 0:
send_message("Error: Game data is corrupted.", sender_id, interface)
update_user_state(sender_id, {'command': 'GAMES', 'step': 1})
handle_games_command(sender_id, interface)
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)
return

try:
choice_index = int(message) - 1

if choice_index < 0 or choice_index * 2 + 1 >= len(choices):
send_message("Invalid selection. Please enter a valid number.", sender_id, interface)
return

target_position = int(choices[choice_index * 2 + 1])

if target_position not in game_map:
send_message("💀 GAME OVER! No further choices available. 💀 Returning to the game menu...", sender_id, interface)
update_user_state(sender_id, {'command': 'GAMES', 'step': 1})
handle_games_command(sender_id, interface)
return

# ✅ FIX: Pass `state` instead of `update_user_state`
state['game_position'] = target_position
update_user_state(sender_id, state)

# ✅ FIX: Pass the correct `state` variable, NOT `update_user_state`
present_story_segment(sender_id, interface, state)

except ValueError:
send_message("Invalid input. Please enter a valid number.", sender_id, interface)
3 changes: 2 additions & 1 deletion example_config.ini
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,14 @@ main_menu_items = Q, B, U, X
bbs_menu_items = M, B, C, J, X

# Default Utilities Menu option for reference
# [G]ames
# [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
utilities_menu_items = G, S, F, W, X


##########################
Expand Down
62 changes: 62 additions & 0 deletions game_line_offset_fix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# This will fix a gamefile that was written with a title="Title" on line 1, but was not offset
# to account for that. If title="Title" is used on line 1, that line should then be treated
# as line 0. The gamefile processor assumes line 1 is the first line that doesn't start with title=
# This script will subtract one from every line number mapping in the gamefile.

# Alternatively, you could add a "dummy line" to the second storyline shifting the rest of
# the file down by one line.

import re
import os

def list_game_files(directory):
return [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]

def adjust_numbers_in_file(input_file, output_file):
with open(input_file, 'r', encoding='utf-8') as f:
lines = f.readlines()

updated_lines = []
for line in lines:
# Preserve title line
if line.startswith("title="):
updated_lines.append(line)
continue

# Find numbers and subtract 1 from each
def adjust_number(match):
return f", {match.group(1)}, {int(match.group(2)) - 1}"

updated_line = re.sub(r",\s*([^,]+)\s*,\s*(\d+)", adjust_number, line)
updated_lines.append(updated_line)

with open(output_file, 'w', encoding='utf-8') as f:
f.writelines(updated_lines)

def main():
games_dir = "./games"
game_files = list_game_files(games_dir)

if not game_files:
print("No files found in the ./games directory.")
return

print("Select a file to process:")
for idx, filename in enumerate(game_files, start=1):
print(f"{idx}: {filename}")

choice = int(input("Enter the number of the file: ")) - 1
if choice < 0 or choice >= len(game_files):
print("Invalid selection.")
return

input_filename = game_files[choice]
input_filepath = os.path.join(games_dir, input_filename)
output_filename = f"{os.path.splitext(input_filename)[0]}-linefix{os.path.splitext(input_filename)[1]}"
output_filepath = os.path.join(games_dir, output_filename)

adjust_numbers_in_file(input_filepath, output_filepath)
print(f"Processed file saved as: {output_filename}")

if __name__ == "__main__":
main()
Loading