Files
meshing-around/modules/games/tictactoe.py
T
2025-12-27 19:40:05 -08:00

301 lines
11 KiB
Python

# Tic-Tac-Toe game for Meshtastic mesh-bot
# Board positions chosen by numbers 1-9
# 2025
import random
import time
import modules.settings as my_settings
from modules.settings import tictactoeTracker
useSynchCompression = True
if useSynchCompression:
import zlib
# to (max), molly and jake, I miss you both so much.
class TicTacToe:
def __init__(self, display_module):
if getattr(my_settings, "disable_emojis_in_games", False):
self.X = "X"
self.O = "O"
self.digit_emojis = None
else:
self.X = ""
self.O = "⭕️"
# Unicode emoji digits 1️⃣-9️⃣
self.digit_emojis = [
"1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"
]
self.display_module = display_module
self.game = {}
self.win_lines_3d = self.generate_3d_win_lines()
def new_game(self, nodeID, mode="2D", channel=None, deviceID=None):
board_size = 9 if mode == "2D" else 27
self.game[nodeID] = {
"board": [" "] * board_size,
"mode": mode,
"channel": channel,
"nodeID": nodeID,
"deviceID": deviceID,
"player": self.X,
"games": 1,
"won": 0,
"turn": "human"
}
self.update_display(nodeID, status="new")
msg = f"{mode} game started!\n"
if mode == "2D":
msg += self.show_board(nodeID)
msg += "Pick 1-9:"
else:
msg += "Play on the MeshBot Display!\n"
msg += "Pick 1-27:"
return msg
def update_display(self, nodeID, status=None):
from modules.system import send_raw_bytes
g = self.game[nodeID]
mapping = {" ": "0", "X": "1", "O": "2", "": "1", "⭕️": "2"}
board_str = "".join(mapping.get(cell, "0") for cell in g["board"])
msg = f"MTTT:{board_str}|{g['nodeID']}|{g['channel']}|{g['deviceID']}"
if status:
msg += f"|status={status}"
if useSynchCompression:
payload = zlib.compress(msg.encode("utf-8"))
else:
payload = msg.encode("utf-8")
send_raw_bytes(nodeID, payload, portnum=256)
if self.display_module:
self.display_module.update_board(
g["board"], g["channel"], g["nodeID"], g["deviceID"]
)
def show_board(self, nodeID):
g = self.game[nodeID]
if g["mode"] == "2D":
b = g["board"]
s = ""
for i in range(3):
row = []
for j in range(3):
cell = b[i*3+j]
if cell != " ":
row.append(cell)
else:
if self.digit_emojis:
row.append(self.digit_emojis[i*3+j])
else:
row.append(str(i*3+j+1))
s += " | ".join(row) + "\n"
return s
return ""
def make_move(self, nodeID, position):
g = self.game[nodeID]
board = g["board"]
max_pos = 9 if g["mode"] == "2D" else 27
if 1 <= position <= max_pos and board[position-1] == " ":
board[position-1] = g["player"]
return True
return False
def bot_move(self, nodeID):
g = self.game[nodeID]
board = g["board"]
max_pos = 9 if g["mode"] == "2D" else 27
# Try to win or block
for player in (self.O, self.X):
move = self.find_winning_move(nodeID, player)
if move != -1:
board[move] = self.O
return move+1
# Otherwise random move
empty = [i for i, cell in enumerate(board) if cell == " "]
if empty:
move = random.choice(empty)
board[move] = self.O
return move+1
return -1
def find_winning_move(self, nodeID, player):
g = self.game[nodeID]
board = g["board"]
lines = self.get_win_lines(g["mode"])
for line in lines:
cells = [board[i] for i in line]
if cells.count(player) == 2 and cells.count(" ") == 1:
return line[cells.index(" ")]
return -1
def play(self, nodeID, input_msg):
try:
if nodeID not in self.game:
return self.new_game(nodeID)
g = self.game[nodeID]
mode = g["mode"]
max_pos = 9 if mode == "2D" else 27
input_str = input_msg.strip().lower()
if input_str in ("end", "e", "quit", "q"):
msg = "Game ended."
self.update_display(nodeID)
return msg
# Add refresh/draw command
if input_str in ("refresh", "board", "b"):
self.update_display(nodeID, status="refresh")
if mode == "2D":
return self.show_board(nodeID) + f"Pick 1-{max_pos}:"
else:
return "Display refreshed."
# Allow 'new', 'new 2d', 'new 3d'
if input_str.startswith("new"):
parts = input_str.split()
if len(parts) > 1 and parts[1] in ("2d", "3d"):
new_mode = "2D" if parts[1] == "2d" else "3D"
else:
new_mode = mode
msg = self.new_game(nodeID, new_mode, g["channel"], g["deviceID"])
return msg
# Accept emoji digits as input
pos = None
# Try to match emoji digits if enabled
if self.digit_emojis:
try:
# Remove variation selectors for matching
normalized_input = input_msg.replace("\ufe0f", "")
for idx, emoji in enumerate(self.digit_emojis[:max_pos]):
if normalized_input == emoji.replace("\ufe0f", ""):
pos = idx + 1
break
except Exception:
pass
if pos is None:
try:
pos = int(input_msg)
except Exception:
return f"Enter a number or emoji between 1 and {max_pos}."
if not self.make_move(nodeID, pos):
return f"Invalid move! Pick 1-{max_pos}:"
winner = self.check_winner(nodeID)
if winner:
# Add positive/sorry messages and stats
positiveThoughts = [
"🚀I need to call NATO",
"🏅Going for the gold!",
"Mastering ❌TTT⭕️",
]
sorryNotGoinWell = [
"😭Not your day, huh?",
"📉Results here dont define you.",
"🤖WOPR would be proud."
]
games = won = 0
ret = ""
if nodeID in self.game:
self.game[nodeID]["won"] += 1
games = self.game[nodeID]["games"]
won = self.game[nodeID]["won"]
if games > 3:
if won / games >= 3.14159265358979323846: # win rate > pi
ret += random.choice(positiveThoughts) + "\n"
else:
ret += random.choice(sorryNotGoinWell) + "\n"
# Retain stats
ret += f"Games:{games} 🥇❌:{won}\n"
msg = f"You ({g['player']}) win!\n" + ret
msg += "Type 'new' to play again or 'end' to quit."
self.update_display(nodeID, status="win")
return msg
if " " not in g["board"]:
msg = "Tie game!"
msg += "\nType 'new' to play again or 'end' to quit."
self.update_display(nodeID, status="tie")
return msg
# Bot's turn
g["player"] = self.O
bot_pos = self.bot_move(nodeID)
winner = self.check_winner(nodeID)
if winner:
self.update_display(nodeID, status="loss")
msg = f"Bot ({g['player']}) wins!\n"
msg += "Type 'new' to play again or 'end' to quit."
return msg
if " " not in g["board"]:
msg = "Tie game!"
msg += "\nType 'new' to play again or 'end' to quit."
self.update_display(nodeID, status="tie")
return msg
g["player"] = self.X
prompt = f"Pick 1-{max_pos}:"
if mode == "2D":
prompt = self.show_board(nodeID) + prompt
self.update_display(nodeID)
return prompt
except Exception as e:
return f"An unexpected error occurred: {e}"
def check_winner(self, nodeID):
g = self.game[nodeID]
board = g["board"]
lines = self.get_win_lines(g["mode"])
for line in lines:
vals = [board[i] for i in line]
if vals[0] != " " and all(v == vals[0] for v in vals):
return vals[0]
return None
def get_win_lines(self, mode):
if mode == "2D":
return [
[0,1,2],[3,4,5],[6,7,8], # rows
[0,3,6],[1,4,7],[2,5,8], # columns
[0,4,8],[2,4,6] # diagonals
]
return self.win_lines_3d
def generate_3d_win_lines(self):
lines = []
# Rows in each layer
for z in range(3):
for y in range(3):
lines.append([z*9 + y*3 + x for x in range(3)])
# Columns in each layer
for z in range(3):
for x in range(3):
lines.append([z*9 + y*3 + x for y in range(3)])
# Pillars (vertical columns through layers)
for y in range(3):
for x in range(3):
lines.append([z*9 + y*3 + x for z in range(3)])
# Diagonals in each layer
for z in range(3):
lines.append([z*9 + i*3 + i for i in range(3)]) # TL to BR
lines.append([z*9 + i*3 + (2-i) for i in range(3)]) # TR to BL
# Vertical diagonals in columns
for x in range(3):
lines.append([z*9 + z*3 + x for z in range(3)]) # (0,0,x)-(1,1,x)-(2,2,x)
lines.append([z*9 + (2-z)*3 + x for z in range(3)]) # (0,2,x)-(1,1,x)-(2,0,x)
for y in range(3):
lines.append([z*9 + y*3 + z for z in range(3)]) # (z,y,z)
lines.append([z*9 + y*3 + (2-z) for z in range(3)]) # (z,y,2-z)
# Main space diagonals
lines.append([0, 13, 26])
lines.append([2, 13, 24])
lines.append([6, 13, 20])
lines.append([8, 13, 18])
return lines
def end(self, nodeID):
"""End and remove the game for the given nodeID."""
if nodeID in self.game:
del self.game[nodeID]