mirror of
https://github.com/SpudGunMan/meshing-around.git
synced 2026-03-28 17:32:36 +01:00
301 lines
11 KiB
Python
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] |