268 lines
8.9 KiB
Python
268 lines
8.9 KiB
Python
"""
|
||
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))
|