""" This file is part of the SplendidBear Websites' projects. Copyright (c) 2026 @ www.splendidbear.org For the full copyright and license information, please view the LICENSE file that was distributed with this source code. """ from __future__ import annotations import random from dataclasses import dataclass, field from typing import Any from mineseeker.constants import GRID_ROWS, GRID_COLS, WIN_THRESHOLD # --------------------------------------------------------------------------- # Cell representation # --------------------------------------------------------------------------- @dataclass class Cell: """Mirrors a single entry in the JS cells array.""" row: int col: int # "hidden" | "safe" | "mine" state: str = "hidden" # Numeric adjacent-mine count (0-8) or "m" for mine, None when unknown value: Any = None # "red", "blue", or None — which player claimed this mine owner: str | None = None # Whether this was the last cell clicked (for highlight) is_last: bool = False # Wave background variant (1 or 2) for unrevealed cells wave: int = 1 # Bomb position overlay strings ("top"/"middle"/"bottom", "left"/"center"/"right") or None bomb_h: str | None = None bomb_v: str | None = None def _init_cells() -> list[list[Cell]]: """Create the initial 16×16 grid of hidden cells (random wave image).""" return [ [Cell(row=r, col=c, wave=random.choice([1, 1, 2])) for c in range(GRID_COLS)] for r in range(GRID_ROWS) ] # --------------------------------------------------------------------------- # Bonus stats # --------------------------------------------------------------------------- @dataclass class BonusStats: blind_hits: int = 0 chain_best: int = 0 chain_current: int = 0 last_mine_hits: int = 0 edge_mines: int = 0 biggest_reveal: int = 0 @classmethod def from_dict(cls, d: dict) -> "BonusStats": return cls( blind_hits=d.get("blindHits", 0), chain_best=d.get("chainBest", 0), chain_current=d.get("chainCurrent", 0), last_mine_hits=d.get("lastMineHits", 0), edge_mines=d.get("edgeMines", 0), biggest_reveal=d.get("biggestReveal", 0), ) # --------------------------------------------------------------------------- # Player snapshot # --------------------------------------------------------------------------- @dataclass class PlayerState: name: str = "" anon_name: str = "" avatar_url: str | None = None mines: int = 0 bonus_points: float = 0.0 bonus_stats: BonusStats = field(default_factory=BonusStats) have_bomb: bool = True bomb_used: bool = False @property def display_name(self) -> str: return self.name or self.anon_name or "Guest" @property def bomb_enabled(self) -> bool: """Mirrors JS: bomb only enabled when player is NOT ahead.""" return self.have_bomb and not self.bomb_used # --------------------------------------------------------------------------- # Game state # --------------------------------------------------------------------------- @dataclass class GameState: cells: list[list[Cell]] = field(default_factory=_init_cells) red: PlayerState = field(default_factory=PlayerState) blue: PlayerState = field(default_factory=PlayerState) # Whose turn it is ("red" or "blue") — blue always starts turn: str = "blue" # Has the game ended? finished: bool = False # Winner ("red", "blue", "draw", or None) winner: str | None = None # Resignation ("red" or "blue" resigned, or None) resigned: str | None = None # Shareable UUID assigned by the server after the first step uuid: str | None = None # The last step coordinates per player { "red": (r,c) | None, ... } last_step: dict[str, tuple[int, int] | None] = field( default_factory=lambda: {"red": None, "blue": None} ) # --------------------------------------------------------------------------- # Apply a step result from the server # --------------------------------------------------------------------------- def apply_step(self, data: dict) -> None: """ Update state from a step payload (TopicManager::publish() result). Mirrors JS applyStep() in useServerCommunication.jsx. """ if data.get("resign"): self.resigned = data["resign"] self.finished = True self.winner = "blue" if data["resign"] == "red" else "red" if data.get("uuid"): self.uuid = data["uuid"] return player: str = data.get("player", "") coords = data.get("coords") if coords: self.last_step[player] = (coords[0], coords[1]) # Clear previous last-step highlights for this player for row in self.cells: for cell in row: if cell.is_last and cell.owner == player: cell.is_last = False self.cells[coords[0]][coords[1]].is_last = True # Reveal cells for rc in data.get("revealedCells", []): r, c, v = rc["row"], rc["col"], rc["value"] cell = self.cells[r][c] if v == "m": cell.state = "mine" cell.value = "m" cell.owner = player else: cell.state = "safe" cell.value = v # Reveal leftover mines at game end for rc in data.get("leftMines", []): r, c = rc["row"], rc["col"] cell = self.cells[r][c] cell.state = "mine" cell.value = "m" # Scores self.red.mines = data.get("redPoints", self.red.mines) self.blue.mines = data.get("bluePoints", self.blue.mines) self.red.bonus_points = data.get("redBonusPoints", self.red.bonus_points) self.blue.bonus_points = data.get("blueBonusPoints", self.blue.bonus_points) if "redBonusStats" in data: self.red.bonus_stats = BonusStats.from_dict(data["redBonusStats"]) if "blueBonusStats" in data: self.blue.bonus_stats = BonusStats.from_dict(data["blueBonusStats"]) if data.get("bomb"): if player == "red": self.red.bomb_used = True else: self.blue.bomb_used = True if data.get("uuid") and not self.finished: self.uuid = data["uuid"] # Win check if self.red.mines >= WIN_THRESHOLD: self.finished = True self.winner = "red" elif self.blue.mines >= WIN_THRESHOLD: self.finished = True self.winner = "blue" # Advance turn (switches after every move) if not self.finished: self.turn = "blue" if player == "red" else "red" # --------------------------------------------------------------------------- # Restore from server connect information # --------------------------------------------------------------------------- def apply_connect(self, data: dict) -> None: """ Restore an existing game from the /api/game/connect payload. Mirrors JS wInit() in useServerCommunication.jsx. """ if not data.get("users"): return # fresh game, nothing to restore users = data["users"] self.red.name = users.get("red", "") self.red.anon_name = users.get("redAnon", "") self.blue.name = users.get("blue", "") self.blue.anon_name = users.get("blueAnon", "") self.red.mines = data.get("redPoints", 0) self.blue.mines = data.get("bluePoints", 0) self.red.bonus_points = data.get("redBonusPoints", 0.0) self.blue.bonus_points = data.get("blueBonusPoints", 0.0) if data.get("redBonusStats"): self.red.bonus_stats = BonusStats.from_dict(data["redBonusStats"]) if data.get("blueBonusStats"): self.blue.bonus_stats = BonusStats.from_dict(data["blueBonusStats"]) # Restore revealed cells (enriched with player colour) for rc in data.get("revealedCells") or []: r, c, v = rc["row"], rc["col"], rc["value"] p = rc.get("player") cell = self.cells[r][c] if v == "m": cell.state = "mine" cell.value = "m" cell.owner = p else: cell.state = "safe" cell.value = v # Restore last-step highlights last = data.get("lastStep", {}) for color in ("red", "blue"): ls = last.get(color) if ls: r, c = ls["row"], ls["col"] self.last_step[color] = (r, c) self.cells[r][c].is_last = True # Determine whose turn it is from mostRecentStep mrs = data.get("mostRecentStep") if mrs: self.turn = "blue" if mrs["player"] == "red" else "red" self.finished = bool(data.get("gameFinished", False))