Files
meshing-around/modules/games/battleship.py
T
SpudGunMan dcaf9d7fb5 Enhance Battleship
Summary
This PR introduces several enhancements to the Battleship game module, focusing on player engagement, game feedback, and strategic depth. The changes include:

Ping Command:
Players can now use the hidden "p" command during their turn to scan a 3x3 area around their last move (or the board center if no moves have been made). The ping provides a hint about the presence of unsunk ships in the area, returning messages like "something lurking nearby" or "targets in the area!". To add suspense and realism, there is a 30% chance the ping will be disrupted, returning "Ping disrupted! No reading. Try again later." This feature does not consume a turn or affect game state, acting as a strategic hint tool.

Game End Statistics:
At the end of each game, players are now informed of the total number of shots fired and the elapsed time to victory. This provides a sense of accomplishment and encourages replayability as players try to improve their efficiency.

Ammo Commentary:
Every 25 and 50 shots, the game displays a humorous comment (e.g., "🥔25 fired!", "🥵50 rounds!") to keep players entertained and aware of their shot count.

How Gameplay Is Improved
Strategic Depth:
The ping feature gives players a way to gather information and adjust their tactics, making gameplay more interactive and less reliant on random guessing.

Player Engagement:
Humorous ammo comments and end-of-game statistics add personality and feedback, making the experience more memorable and fun.

Replay Value:
By surfacing stats like shots fired and time taken, players are motivated to replay and beat their previous records.

Fairness and Clarity:
The ping command does not advance the turn or affect the game state, ensuring fairness and preventing accidental misuse.

Testing & Compatibility
All new features are integrated with both AI and P2P modes.
Existing gameplay logic and win conditions remain unchanged.
The ping command is robust against edge cases (e.g., no moves made, board edges).
In summary:
This PR makes Battleship more engaging, strategic, and fun, while preserving the integrity of the original game mechanics.

Co-Authored-By: NillRudd <102033730+nillrudd@users.noreply.github.com>
2025-11-04 11:10:38 -08:00

509 lines
19 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Battleship game module Meshing Around
# 2025 K7MHI Kelly Keeton
import random
import copy
import uuid
import time
OCEAN = "~"
FIRE = "x"
HIT = "*"
SIZE = 10
SHIPS = [5, 4, 3, 3, 2]
SHIP_NAMES = ["✈️Carrier", "Battleship", "Cruiser", "Submarine", "Destroyer"]
class Session:
def __init__(self, player1_id, player2_id=None, vs_ai=True):
self.session_id = str(uuid.uuid4())
self.vs_ai = vs_ai
self.player1_id = player1_id
self.player2_id = player2_id
self.game = Battleship(vs_ai=vs_ai)
self.next_turn = player1_id
self.last_move = None
self.shots_fired = 0
self.start_time = time.time()
class Battleship:
sessions = {}
short_codes = {}
@classmethod
def _generate_short_code(cls):
while True:
code = str(random.randint(1000, 9999))
if code not in cls.short_codes:
return code
@classmethod
def new_game(cls, player_id, vs_ai=True, p2p_id=None):
session = Session(player1_id=player_id, player2_id=p2p_id, vs_ai=vs_ai)
cls.sessions[session.session_id] = session
if not vs_ai:
code = cls._generate_short_code()
cls.short_codes[code] = session.session_id
msg = (
"New 🚢Battleship🚢 game started!\n"
"Joining player goes first, waiting for them to join...\n"
f"Share\n'battleship join {code}'"
)
return msg, code
else:
msg = (
"New 🚢Battleship🤖 game started!\n"
"Enter your move using coordinates: row-letter, column-number.\n"
"Example: B5 or C,7\n"
"Type 'exit' or 'end' to quit the game."
)
return msg, session.session_id
@classmethod
def end_game(cls, session_id):
if session_id in cls.sessions:
del cls.sessions[session_id]
return "Thanks for playing 🚢Battleship🚢"
@classmethod
def get_session(cls, code_or_session_id):
session_id = cls.short_codes.get(code_or_session_id, code_or_session_id)
return cls.sessions.get(session_id)
def __init__(self, vs_ai=True):
if vs_ai:
self.player_board = self._blank_board()
self.ai_board = self._blank_board()
self.player_radar = self._blank_board()
self.ai_radar = self._blank_board()
self.number_board = self._blank_board()
self.player_alive = sum(SHIPS)
self.ai_alive = sum(SHIPS)
self._place_ships(self.player_board, self.number_board)
self._place_ships(self.ai_board)
self.ai_targets = []
self.ai_last_hit = None
self.ai_orientation = None
else:
# P2P: Each player has their own board and radar
self.player1_board = self._blank_board()
self.player2_board = self._blank_board()
self.player1_radar = self._blank_board()
self.player2_radar = self._blank_board()
self.player1_alive = sum(SHIPS)
self.player2_alive = sum(SHIPS)
self._place_ships(self.player1_board)
self._place_ships(self.player2_board)
def _blank_board(self):
return [[OCEAN for _ in range(SIZE)] for _ in range(SIZE)]
def _place_ships(self, board, number_board=None):
for idx, ship_len in enumerate(SHIPS):
placed = False
while not placed:
vertical = random.choice([True, False])
if vertical:
row = random.randint(0, SIZE - ship_len)
col = random.randint(0, SIZE - 1)
if all(board[row + i][col] == OCEAN for i in range(ship_len)):
for i in range(ship_len):
board[row + i][col] = str(idx)
if number_board is not None:
number_board[row + i][col] = idx
placed = True
else:
row = random.randint(0, SIZE - 1)
col = random.randint(0, SIZE - ship_len)
if all(board[row][col + i] == OCEAN for i in range(ship_len)):
for i in range(ship_len):
board[row][col + i] = str(idx)
if number_board is not None:
number_board[row][col + i] = idx
placed = True
def player_move(self, row, col):
"""Player fires at AI's board. Returns 'hit', 'miss', or 'sunk:<ship_idx>'."""
if self.player_radar[row][col] != OCEAN:
return "repeat"
if self.ai_board[row][col] not in (OCEAN, FIRE, HIT):
self.player_radar[row][col] = HIT
ship_idx = int(self.ai_board[row][col])
self.ai_board[row][col] = HIT
if self._is_ship_sunk(self.ai_board, ship_idx):
self.ai_alive -= SHIPS[ship_idx]
return f"sunk:{ship_idx}"
return "hit"
else:
self.player_radar[row][col] = FIRE
self.ai_board[row][col] = FIRE
return "miss"
def ai_move(self):
"""AI fires at player's board. Returns (row, col, result or 'sunk:<ship_idx>')."""
while True:
row = random.randint(0, SIZE - 1)
col = random.randint(0, SIZE - 1)
if self.ai_radar[row][col] == OCEAN:
break
if self.player_board[row][col] not in (OCEAN, FIRE, HIT):
self.ai_radar[row][col] = HIT
ship_idx = int(self.player_board[row][col])
self.player_board[row][col] = HIT
if self._is_ship_sunk(self.player_board, ship_idx):
self.player_alive -= SHIPS[ship_idx]
return row, col, f"sunk:{ship_idx}"
return row, col, "hit"
else:
self.ai_radar[row][col] = FIRE
self.player_board[row][col] = FIRE
return row, col, "miss"
def p2p_player_move(self, row, col, attacker, defender, radar, defender_alive_attr):
"""P2P: attacker fires at defender's board, updates radar and defender's board."""
if radar[row][col] != OCEAN:
return "repeat"
if defender[row][col] not in (OCEAN, FIRE, HIT):
radar[row][col] = HIT
ship_idx = int(defender[row][col])
defender[row][col] = HIT
if self._is_ship_sunk(defender, ship_idx):
setattr(self, defender_alive_attr, getattr(self, defender_alive_attr) - SHIPS[ship_idx])
return f"sunk:{ship_idx}"
return "hit"
else:
radar[row][col] = FIRE
defender[row][col] = FIRE
return "miss"
def _is_ship_sunk(self, board, ship_idx):
for row in board:
for cell in row:
if cell == str(ship_idx):
return False
return True
def is_game_over(self, vs_ai=True):
if vs_ai:
return self.player_alive == 0 or self.ai_alive == 0
else:
return self.player1_alive == 0 or self.player2_alive == 0
def get_player_board(self):
return copy.deepcopy(self.player_board)
def get_player_radar(self):
return copy.deepcopy(self.player_radar)
def get_ai_board(self):
return copy.deepcopy(self.ai_board)
def get_ai_radar(self):
return copy.deepcopy(self.ai_radar)
def get_ship_status(self, board):
status = {}
for idx in range(len(SHIPS)):
afloat = any(str(idx) in row for row in board)
status[idx] = "Afloat" if afloat else "Sunk"
return status
def display_draw_board(self, board, label="Board"):
print(f"{label}")
print(" " + " ".join(str(i+1).rjust(2) for i in range(SIZE)))
for idx, row in enumerate(board):
print(chr(ord('A') + idx) + " " + " ".join(cell.rjust(2) for cell in row))
def get_short_name(node_id):
from mesh_bot import battleshipTracker
entry = next((e for e in battleshipTracker if e['nodeID'] == node_id), None)
return entry['short_name'] if entry and 'short_name' in entry else str(node_id)
def playBattleship(message, nodeID, deviceID, session_id=None):
if not session_id or session_id not in Battleship.sessions:
return Battleship.new_game(nodeID, vs_ai=True)
session = Battleship.get_session(session_id)
game = session.game
# Check for game over
if not session.vs_ai and game.is_game_over(vs_ai=False):
winner = None
if game.player1_alive == 0:
winner = get_short_name(session.player2_id)
elif game.player2_alive == 0:
winner = get_short_name(session.player1_id)
else:
winner = "Nobody"
elapsed = int(time.time() - session.start_time)
mins, secs = divmod(elapsed, 60)
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
shots = session.shots_fired
return (
f"Game over! {winner} wins! 🚢🏆\n"
f"Game finished in {shots} shots and {time_str}.\n"
)
if not session.vs_ai and session.player2_id is None:
code = next((k for k, v in Battleship.short_codes.items() if v == session.session_id), None)
return (
f"Waiting for another player to join.\n"
f"Share this code: {code}\n"
"Type 'end' to cancel this P2P game."
)
if nodeID != session.next_turn:
return "It's not your turn!"
msg = message.strip().lower()
if msg.startswith("battleship"):
msg = msg[len("battleship"):].strip()
if msg.startswith("b:"):
msg = msg[2:].strip()
msg = msg.replace(" ", "")
# --- Ping Command ---
if msg == "p":
import random
# 30% chance to fail
if random.random() < 0.3:
return "I can hear a couple of 🦞lobsters dukin' it out down there..."
# Determine center of ping
if session.vs_ai:
# Use last move if available, else center of board
if session.shots_fired > 0:
# Find last move coordinates from radar (most recent HIT or FIRE)
radar = game.get_player_radar()
found = False
for i in range(SIZE):
for j in range(SIZE):
if radar[i][j] in (HIT, FIRE):
center_y, center_x = i, j
found = True
if not found:
center_y, center_x = SIZE // 2, SIZE // 2
else:
center_y, center_x = SIZE // 2, SIZE // 2
# Scan 3x3 area on AI board for unsunk ship cells
board = game.ai_board
else:
# For P2P, use player's radar and opponent's board
if session.last_move:
coord = session.last_move[1]
center_y = ord(coord[0]) - ord('A')
center_x = int(coord[1:]) - 1
else:
center_y, center_x = SIZE // 2, SIZE // 2
# Scan 3x3 area on opponent's board
if nodeID == session.player1_id:
board = game.player2_board
else:
board = game.player1_board
min_y = max(0, center_y - 1)
max_y = min(SIZE, center_y + 2)
min_x = max(0, center_x - 1)
max_x = min(SIZE, center_x + 2)
ship_cells = set()
for i in range(min_y, max_y):
for j in range(min_x, max_x):
cell = board[i][j]
if cell.isdigit():
ship_cells.add(cell)
pong_count = len(ship_cells)
if pong_count == 0:
return "silence in the deep..."
elif pong_count == 1:
return "something lurking nearby."
else:
return f"targets in the area!"
x = y = None
if "," in msg:
parts = msg.split(",")
if len(parts) == 2 and len(parts[0]) == 1 and parts[0].isalpha() and parts[1].isdigit():
y = ord(parts[0]) - ord('a')
x = int(parts[1]) - 1
else:
return "Invalid coordinates. Use format A2 or A,2 (row letter, column number)."
elif len(msg) >= 2 and msg[0].isalpha() and msg[1:].isdigit():
y = ord(msg[0]) - ord('a')
x = int(msg[1:]) - 1
else:
return "Invalid command. Use format A2 or A,2 (row letter, column number)."
if x is None or y is None or not (0 <= x < SIZE and 0 <= y < SIZE):
return "Coordinates out of range."
ai_row = ai_col = ai_result = None
over = False
if session.vs_ai:
result = game.player_move(y, x)
ai_row, ai_col, ai_result = game.ai_move()
over = game.is_game_over(vs_ai=True)
else:
# P2P: determine which player is moving and fire at the other player's board
if nodeID == session.player1_id:
attacker = "player1"
defender = "player2"
result = game.p2p_player_move(
y, x,
game.player1_board, game.player2_board,
game.player1_radar, "player2_alive"
)
else:
attacker = "player2"
defender = "player1"
result = game.p2p_player_move(
y, x,
game.player2_board, game.player1_board,
game.player2_radar, "player1_alive"
)
over = game.is_game_over(vs_ai=False)
coord_str = f"{chr(y+65)}{x+1}"
session.last_move = (nodeID, coord_str, result)
# --- DEBUG DISPLAY ---
DEBUG = False
if DEBUG:
if session.vs_ai:
game.display_draw_board(game.player_board, label=f"Player Board ({session.player1_id})")
game.display_draw_board(game.player_radar, label="Player Radar")
game.display_draw_board(game.ai_board, label="AI Board")
game.display_draw_board(game.ai_radar, label="AI Radar")
else:
p1_id = session.player1_id
p2_id = session.player2_id if session.player2_id else "Waiting"
game.display_draw_board(game.player1_board, label=f"Player 1 Board ({p1_id})")
game.display_draw_board(game.player1_radar, label="Player 1 Radar")
game.display_draw_board(game.player2_board, label=f"Player 2 Board ({p2_id})")
game.display_draw_board(game.player2_radar, label="Player 2 Radar")
# Format radar as a 4x4 grid centered on the player's move
if session.vs_ai:
radar = game.get_player_radar()
else:
radar = game.player1_radar if nodeID == session.player1_id else game.player2_radar
window_size = 4
half_window = window_size // 2
min_row = max(0, min(y - half_window, SIZE - window_size))
max_row = min(SIZE, min_row + window_size)
min_col = max(0, min(x - half_window, SIZE - window_size))
max_col = min(SIZE, min_col + window_size)
radar_str = "🗺️" + " ".join(str(i+1) for i in range(min_col, max_col)) + "\n"
for idx in range(min_row, max_row):
radar_str += chr(ord('A') + idx) + " :" + " ".join(radar[idx][j] for j in range(min_col, max_col)) + "\n"
def format_ship_status(status_dict):
afloat = 0
for idx, state in status_dict.items():
if state == "Afloat":
afloat += 1
return f"{afloat}/{len(SHIPS)} afloat"
if session.vs_ai:
ai_status_str = format_ship_status(game.get_ship_status(game.ai_board))
player_status_str = format_ship_status(game.get_ship_status(game.player_board))
else:
ai_status_str = format_ship_status(game.get_ship_status(game.player2_board))
player_status_str = format_ship_status(game.get_ship_status(game.player1_board))
def move_result_text(res, is_player=True):
if res.startswith("sunk:"):
idx = int(res.split(":")[1])
name = SHIP_NAMES[idx]
return f"Sunk🎯 {name}!"
elif res == "hit":
return "💥Hit!"
elif res == "miss":
return "missed"
elif res == "repeat":
return "📋already targeted"
else:
return res
# After a valid move, switch turns
if session.vs_ai:
session.next_turn = nodeID
else:
session.next_turn = session.player2_id if nodeID == session.player1_id else session.player1_id
# Increment shots fired
session.shots_fired += 1
# Waste of ammo comment
funny_comment = ""
if session.shots_fired % 50 == 0:
funny_comment = f"\n🥵{session.shots_fired} rounds!"
elif session.shots_fired % 25 == 0:
funny_comment = f"\n🥔{session.shots_fired} fired!"
# Output message
if session.vs_ai:
msg_out = (
f"Your move: {move_result_text(result)}\n"
f"AI ships: {ai_status_str}\n"
f"Radar:\n{radar_str}"
f"AI move: {chr(ai_row+65)}{ai_col+1} ({move_result_text(ai_result, False)})\n"
f"Your ships: {player_status_str}"
f"{funny_comment}"
)
else:
my_name = get_short_name(nodeID)
opponent_id = session.player2_id if nodeID == session.player1_id else session.player1_id
opponent_short_name = get_short_name(opponent_id) if opponent_id else "Waiting"
opponent_label = f"{opponent_short_name}:"
my_move_result_str = f"Your move: {move_result_text(result)}\n"
last_move_str = ""
if session.last_move and session.last_move[0] != nodeID:
last_player_short_name = get_short_name(session.last_move[0])
last_coord = session.last_move[1]
last_result = move_result_text(session.last_move[2])
last_move_str = f"Last move by {last_player_short_name}: {last_coord} ({last_result})\n"
if session.next_turn == nodeID:
turn_prompt = f"Your turn, {my_name}! Enter your move:"
else:
turn_prompt = f"Waiting for {opponent_short_name}..."
msg_out = (
f"{my_move_result_str}"
f"{last_move_str}"
f"{opponent_label} {ai_status_str}\n"
f"Radar:\n{radar_str}"
f"Your ships: {player_status_str}\n"
f"{turn_prompt}"
f"{funny_comment}"
)
if over:
elapsed = int(time.time() - session.start_time)
mins, secs = divmod(elapsed, 60)
time_str = f"{mins}m {secs}s" if mins else f"{secs}s"
shots = session.shots_fired
if session.vs_ai:
if game.player_alive == 0:
winner = "AI 🤖"
msg_out += f"\nGame over! {winner} wins! Better luck next time.\n"
else:
winner = get_short_name(nodeID)
msg_out += (
f"\nGame over! {winner} wins! You sank all the AI's ships! 🎉\n"
f"Took {shots} shots in {time_str}.\n"
)
else:
# P2P: Announce winner by short name
if game.player1_alive == 0:
winner = get_short_name(session.player2_id)
elif game.player2_alive == 0:
winner = get_short_name(session.player1_id)
else:
winner = "Nobody"
msg_out += (
f"\nGame over! {winner} wins! 🚢🏆\n"
f"Game finished in {shots} shots and {time_str}.\n"
)
msg_out += "Type 'battleship' to start a new game."
return msg_out