new: dev: initialize the GTK client #11
This commit is contained in:
0
gtk-client/mineseeker/state/__init__.py
Normal file
0
gtk-client/mineseeker/state/__init__.py
Normal file
267
gtk-client/mineseeker/state/game_state.py
Normal file
267
gtk-client/mineseeker/state/game_state.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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))
|
||||
45
gtk-client/mineseeker/state/session.py
Normal file
45
gtk-client/mineseeker/state/session.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Holds the current player's identity and game association."""
|
||||
|
||||
# Username (real user) or "anon_<session_id>" for guests
|
||||
username: str = ""
|
||||
|
||||
# Whether this is an authenticated (non-guest) user
|
||||
is_authenticated: bool = False
|
||||
|
||||
# Current game association UUID
|
||||
game_assoc: str = ""
|
||||
|
||||
# "red" or "blue" — assigned when both players are subscribed
|
||||
color: str = ""
|
||||
|
||||
# Mercure subscriber JWT for the current game
|
||||
mercure_jwt: str = ""
|
||||
|
||||
|
||||
# Module-level singleton; reset on logout
|
||||
_session: Session = Session()
|
||||
|
||||
|
||||
def get() -> Session:
|
||||
return _session
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
global _session
|
||||
_session = Session()
|
||||
Reference in New Issue
Block a user