Private
Public Access
1
0
Files
MineSeeker/gtk-client/mineseeker/state/game_state.py

268 lines
8.9 KiB
Python
Raw Normal View History

"""
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))