# src/modules/backend/game_engine.py
"""
Game engine module for handling the game logic when the computer selects a target word.
"""
import secrets # More secure random number generation
import string
from typing import Dict, List, Tuple
from ...events.enums import GameState
from ...events.event import GameEndedEvent, GameStartedEvent
from ...events.observer import GameEventBus
from ..logging_utils import log_method, logger, set_game_id
from .exceptions import (
GameStateError,
InputLengthError,
InvalidGuessError,
InvalidWordError,
)
from .result_color import ResultColor
from .solver.constants import DEFAULT_GAME_ID_LENGTH, DEFAULT_MAX_ATTEMPTS
[docs]
class GameEngine:
"""Handles the computer vs player game mode."""
[docs]
@log_method("DEBUG")
def __init__(self, event_bus: "GameEventBus" = None):
"""
Initialize a new GameEngine instance.
The GameEngine maintains strict encapsulation by creating and controlling
its own WordManager and StatsManager instances.
"""
from .stateless_word_manager import (
StatelessWordManager, # Import for internal use
)
from .stats_manager import StatsManager # Import here to avoid circular imports
self.event_bus = event_bus or GameEventBus()
self.word_manager = StatelessWordManager()
self.stats_manager = StatsManager(event_bus=self.event_bus)
self.target_word = ""
self.guesses: List[Tuple[str, str]] = []
self.max_guesses = DEFAULT_MAX_ATTEMPTS
self.game_active = False
self._game_id = ""
self.game_mode = "auto" # Default game mode
@property
def game_id(self) -> str:
"""Get the current game ID or raise an exception if there is no active game."""
if not self._game_id:
raise GameStateError("No active game. Call start_new_game() first.")
return self._game_id
@game_id.setter
def game_id(self, value: str) -> None:
"""
Set the game ID. If None or empty string is provided,
a new random 6-character alphanumeric ID will be generated automatically.
"""
if value is None or value.strip() == "":
# Generate a new game ID
characters = string.ascii_uppercase + string.digits
self._game_id = "".join(
secrets.choice(characters) for _ in range(DEFAULT_GAME_ID_LENGTH)
)
else:
self._game_id = value
if logger is not None:
logger.info(f"New game ID: {self._game_id}")
[docs]
@log_method("DEBUG")
def start_new_game(self) -> str:
"""Start a new game by selecting a random target word."""
# Prefer common words for the target, but fall back to all words if needed
common_words = list(self.word_manager.common_words)
all_words = list(self.word_manager.all_words)
if common_words:
# 70% chance to pick from common words, 30% from all words
if secrets.randbelow(10) < 7: # 7 out of 10 = 70%
self.target_word = secrets.choice(common_words)
else:
self.target_word = secrets.choice(all_words)
else:
self.target_word = secrets.choice(all_words) if all_words else "AUDIO"
self.guesses = []
self.game_active = True
self.game_id = "" # This will trigger the setter to generate a new ID
# Set the game ID in the logging context
set_game_id(self._game_id)
# Publish game started event
if self.event_bus:
self.event_bus.publish(
GameStartedEvent(
game_id=self._game_id, mode=self.game_mode, source="GameEngine"
)
)
return self.target_word
[docs]
@log_method("DEBUG")
def make_guess(self, guess: str, mode: str = None) -> Tuple[str, bool]:
"""
Make a guess and return the result pattern and whether the game is won.
Args:
guess: The word being guessed
mode: Optional game mode identifier (manual, solver, etc.)
Returns:
(result_pattern, is_solved)
"""
if not self.game_active:
raise GameStateError("No active game. Call start_new_game() first.")
# Update game mode if provided
if mode:
self.game_mode = mode
guess = guess.upper()
if len(guess) != 5:
raise InputLengthError("Guess", len(guess))
if not guess.isalpha():
raise InvalidGuessError(guess, "must contain only letters")
# Check if word is valid, bypassing validation if word_manager is in test mode
if not self.word_manager.is_test_mode() and not self.word_manager.is_valid_word(
guess
):
raise InvalidWordError(guess)
result = self._calculate_result(guess, self.target_word)
self.guesses.append((guess, result))
is_solved = result == ResultColor.GREEN.value * 5
game_over = is_solved or len(self.guesses) >= self.max_guesses
if game_over:
self.game_active = False
# Automatically save the game when it ends
self._save_game(is_solved)
# Publish game ended event
if self.event_bus:
self.event_bus.publish(
GameEndedEvent(
game_id=self._game_id,
state=GameState.GAME_OVER,
guesses=len(self.guesses),
is_won=is_solved,
target_word=self.target_word,
mode=self.game_mode,
source="GameEngine",
)
)
return result, is_solved
@log_method("DEBUG")
def _save_game(self, is_solved: bool) -> None:
"""
Save the completed game to history.
Args:
is_solved: Whether the game was won
"""
if self.stats_manager:
try:
self.stats_manager._record_game(
self.guesses,
is_solved,
len(self.guesses),
game_id=self._game_id,
target_word=self.target_word,
mode=self.game_mode,
)
except Exception as e:
# Log the error but don't prevent game from ending
if logger:
logger.error(f"Failed to save game: {e}")
@log_method("DEBUG")
def _calculate_result(self, guess: str, target: str) -> str:
"""Calculate the result pattern for a guess against the target word."""
result = [ResultColor.BLACK.value] * 5
guess = guess.upper()
target = target.upper()
# Create a dictionary to track remaining target letters
target_chars = {}
for char in target:
if char not in target_chars:
target_chars[char] = 0
target_chars[char] += 1
# First pass: Mark all correct positions (greens)
for i, (g_char, t_char) in enumerate(zip(guess, target)):
if g_char == t_char:
result[i] = ResultColor.GREEN.value
target_chars[g_char] -= 1
# Second pass: Mark misplaced letters (yellows)
for i, g_char in enumerate(guess):
if (
result[i] != ResultColor.GREEN.value
and g_char in target_chars
and target_chars[g_char] > 0
):
result[i] = ResultColor.YELLOW.value
target_chars[g_char] -= 1
return "".join(result)
[docs]
@log_method("DEBUG")
def get_remaining_guesses(self) -> int:
"""Get number of remaining guesses."""
return self.max_guesses - len(self.guesses)
[docs]
@log_method("DEBUG")
def is_game_won(self) -> bool:
"""Check if the game has been won."""
return bool(self.guesses and self.guesses[-1][1] == ResultColor.GREEN.value * 5)
[docs]
@log_method("DEBUG")
def is_game_over(self) -> bool:
"""Check if the game is over (won or max guesses reached)."""
return (
self.is_game_won()
or len(self.guesses) >= self.max_guesses
or not self.game_active
)
[docs]
@log_method("DEBUG")
def get_game_state(self) -> Dict[str, object]:
"""Get current game state."""
is_won = self.is_game_won()
is_over = self.is_game_over()
return {
"active": self.game_active,
"guesses_made": len(self.guesses),
"guesses_remaining": self.get_remaining_guesses(),
"is_won": is_won,
"is_over": is_over,
"guesses_history": self.guesses.copy(),
"target_word": self.target_word if is_over else None,
"game_id": self.game_id,
}
[docs]
@log_method("DEBUG")
def get_hint(self) -> str:
"""Get a hint for the current game."""
if not self.game_active:
return "No active game."
# Choose a random position that hasn't been guessed correctly
incorrect_positions = []
if not self.guesses:
# For first guess, hint at a random letter from the target
pos = secrets.randbelow(5) # Generate number between 0 and 4 inclusive
return (
f"The word contains the letter '{self.target_word[pos]}'. "
f"Try starting with a word like 'ADIEU' or 'AUDIO' that contains many vowels."
)
# Find positions that haven't been guessed correctly
last_guess = self.guesses[-1]
for i, result_char in enumerate(last_guess[1]):
if result_char != "G":
incorrect_positions.append(i)
if not incorrect_positions:
return "You've already guessed all letters correctly!"
# Choose a random incorrect position
# Need to implement secrets.choice as it's not directly available
pos = incorrect_positions[secrets.randbelow(len(incorrect_positions))]
return f"The letter in position {pos+1} is '{self.target_word[pos]}'."