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