mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 06:45:02 +00:00
Initial scaffold for ShellMate - SSH chess TUI
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
*.pem
|
||||||
|
*_key
|
||||||
|
ssh_host_*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Install Stockfish
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
stockfish \
|
||||||
|
openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY pyproject.toml .
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY src/ src/
|
||||||
|
|
||||||
|
# Generate SSH host key
|
||||||
|
RUN mkdir -p /etc/shellmate && \
|
||||||
|
ssh-keygen -t ed25519 -f /etc/shellmate/ssh_host_key -N ""
|
||||||
|
|
||||||
|
# Run as non-root
|
||||||
|
RUN useradd -m shellmate
|
||||||
|
USER shellmate
|
||||||
|
|
||||||
|
EXPOSE 2222
|
||||||
|
|
||||||
|
CMD ["shellmate-server"]
|
||||||
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ♟️ ShellMate
|
||||||
|
|
||||||
|
**SSH into chess mastery.**
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh play@shellmate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
A terminal-based chess experience over SSH. Play against AI, challenge friends, or learn the game with interactive tutorials.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎮 Game Modes
|
||||||
|
- **vs AI** — Challenge Stockfish at adjustable difficulty levels
|
||||||
|
- **vs Player** — Real-time PvP matchmaking
|
||||||
|
- **vs Friend** — Private rooms with shareable codes
|
||||||
|
|
||||||
|
### 📚 Learn
|
||||||
|
- **Interactive Tutorials** — From basics to advanced tactics
|
||||||
|
- **Move Analysis** — AI explains why each move matters
|
||||||
|
- **Puzzle Rush** — Tactical training exercises
|
||||||
|
- **Opening Explorer** — Learn popular openings with explanations
|
||||||
|
|
||||||
|
### 🏆 Features
|
||||||
|
- ELO rating system
|
||||||
|
- Game history & replay
|
||||||
|
- Multiple board themes
|
||||||
|
- Move hints & analysis
|
||||||
|
- Chat in PvP games
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect and play
|
||||||
|
ssh play@shellmate.sh
|
||||||
|
|
||||||
|
# Spectate a game
|
||||||
|
ssh watch@shellmate.sh
|
||||||
|
|
||||||
|
# Tutorial mode
|
||||||
|
ssh learn@shellmate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Python 3.11+**
|
||||||
|
- **Textual** — Modern TUI framework
|
||||||
|
- **python-chess** — Chess logic & notation
|
||||||
|
- **Stockfish** — AI engine
|
||||||
|
- **asyncssh** — SSH server
|
||||||
|
- **Redis** — Matchmaking & sessions
|
||||||
|
- **PostgreSQL** — User data & game history
|
||||||
|
|
||||||
|
## Self-Hosting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/self-hosting.md](docs/self-hosting.md) for configuration.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built with ♟️ by Greg Hendrickson*
|
||||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
shellmate:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "2222:2222"
|
||||||
|
environment:
|
||||||
|
- SHELLMATE_SSH_PORT=2222
|
||||||
|
- SHELLMATE_REDIS_URL=redis://redis:6379
|
||||||
|
- SHELLMATE_DATABASE_URL=postgresql://shellmate:shellmate@postgres:5432/shellmate
|
||||||
|
- STOCKFISH_PATH=/usr/games/stockfish
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nc", "-z", "localhost", "2222"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=shellmate
|
||||||
|
- POSTGRES_PASSWORD=shellmate
|
||||||
|
- POSTGRES_DB=shellmate
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U shellmate"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
postgres_data:
|
||||||
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
[project]
|
||||||
|
name = "shellmate"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SSH into chess mastery - Terminal chess over SSH"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [{ name = "Greg Hendrickson", email = "greg@gregh.dev" }]
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"textual>=0.50.0",
|
||||||
|
"python-chess>=1.10.0",
|
||||||
|
"asyncssh>=2.14.0",
|
||||||
|
"redis>=5.0.0",
|
||||||
|
"asyncpg>=0.29.0",
|
||||||
|
"stockfish>=3.28.0",
|
||||||
|
"rich>=13.7.0",
|
||||||
|
"pydantic>=2.5.0",
|
||||||
|
"pydantic-settings>=2.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"ruff>=0.2.0",
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
shellmate = "shellmate.cli:main"
|
||||||
|
shellmate-server = "shellmate.ssh.server:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
strict = true
|
||||||
3
src/shellmate/__init__.py
Normal file
3
src/shellmate/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""ShellMate - SSH into chess mastery."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
5
src/shellmate/ai/__init__.py
Normal file
5
src/shellmate/ai/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""AI engine integration."""
|
||||||
|
|
||||||
|
from .engine import ChessAI
|
||||||
|
|
||||||
|
__all__ = ["ChessAI"]
|
||||||
158
src/shellmate/ai/engine.py
Normal file
158
src/shellmate/ai/engine.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Stockfish AI engine wrapper with move explanations."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
import chess
|
||||||
|
from stockfish import Stockfish
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MoveAnalysis:
|
||||||
|
"""Analysis of a chess position/move."""
|
||||||
|
best_move: str
|
||||||
|
evaluation: float # centipawns, positive = white advantage
|
||||||
|
depth: int
|
||||||
|
pv: list[str] # principal variation
|
||||||
|
explanation: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChessAI:
|
||||||
|
"""Chess AI powered by Stockfish with explanations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
stockfish_path: str = "/usr/bin/stockfish",
|
||||||
|
skill_level: int = 10,
|
||||||
|
think_time_ms: int = 1000,
|
||||||
|
):
|
||||||
|
self.engine = Stockfish(path=stockfish_path)
|
||||||
|
self.engine.set_skill_level(skill_level)
|
||||||
|
self.think_time_ms = think_time_ms
|
||||||
|
self._skill_level = skill_level
|
||||||
|
|
||||||
|
def set_difficulty(self, level: int) -> None:
|
||||||
|
"""Set AI difficulty (0-20)."""
|
||||||
|
self._skill_level = max(0, min(20, level))
|
||||||
|
self.engine.set_skill_level(self._skill_level)
|
||||||
|
|
||||||
|
def get_best_move(self, fen: str) -> str:
|
||||||
|
"""Get the best move for the current position."""
|
||||||
|
self.engine.set_fen_position(fen)
|
||||||
|
return self.engine.get_best_move_time(self.think_time_ms)
|
||||||
|
|
||||||
|
def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis:
|
||||||
|
"""Analyze a position and return detailed analysis."""
|
||||||
|
self.engine.set_fen_position(fen)
|
||||||
|
self.engine.set_depth(depth)
|
||||||
|
|
||||||
|
evaluation = self.engine.get_evaluation()
|
||||||
|
best_move = self.engine.get_best_move()
|
||||||
|
top_moves = self.engine.get_top_moves(3)
|
||||||
|
|
||||||
|
# Convert evaluation to centipawns
|
||||||
|
if evaluation["type"] == "cp":
|
||||||
|
eval_cp = evaluation["value"]
|
||||||
|
else: # mate
|
||||||
|
eval_cp = 10000 if evaluation["value"] > 0 else -10000
|
||||||
|
|
||||||
|
# Generate explanation
|
||||||
|
explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves)
|
||||||
|
|
||||||
|
return MoveAnalysis(
|
||||||
|
best_move=best_move,
|
||||||
|
evaluation=eval_cp / 100, # convert to pawns
|
||||||
|
depth=depth,
|
||||||
|
pv=[m["Move"] for m in top_moves] if top_moves else [best_move],
|
||||||
|
explanation=explanation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_explanation(
|
||||||
|
self,
|
||||||
|
fen: str,
|
||||||
|
best_move: str,
|
||||||
|
eval_cp: int,
|
||||||
|
top_moves: list,
|
||||||
|
) -> str:
|
||||||
|
"""Generate human-readable explanation for a move."""
|
||||||
|
board = chess.Board(fen)
|
||||||
|
move = chess.Move.from_uci(best_move)
|
||||||
|
san = board.san(move)
|
||||||
|
piece = board.piece_at(move.from_square)
|
||||||
|
|
||||||
|
explanations = []
|
||||||
|
|
||||||
|
# Describe the move
|
||||||
|
piece_name = chess.piece_name(piece.piece_type).capitalize() if piece else "Piece"
|
||||||
|
explanations.append(f"{piece_name} to {chess.square_name(move.to_square)} ({san})")
|
||||||
|
|
||||||
|
# Check for captures
|
||||||
|
if board.is_capture(move):
|
||||||
|
captured = board.piece_at(move.to_square)
|
||||||
|
if captured:
|
||||||
|
explanations.append(f"Captures {chess.piece_name(captured.piece_type)}")
|
||||||
|
|
||||||
|
# Check for checks
|
||||||
|
board.push(move)
|
||||||
|
if board.is_check():
|
||||||
|
if board.is_checkmate():
|
||||||
|
explanations.append("Checkmate!")
|
||||||
|
else:
|
||||||
|
explanations.append("Puts the king in check")
|
||||||
|
board.pop()
|
||||||
|
|
||||||
|
# Evaluation context
|
||||||
|
if abs(eval_cp) < 50:
|
||||||
|
explanations.append("Position is roughly equal")
|
||||||
|
elif eval_cp > 300:
|
||||||
|
explanations.append("White has a significant advantage")
|
||||||
|
elif eval_cp < -300:
|
||||||
|
explanations.append("Black has a significant advantage")
|
||||||
|
elif eval_cp > 0:
|
||||||
|
explanations.append("White is slightly better")
|
||||||
|
else:
|
||||||
|
explanations.append("Black is slightly better")
|
||||||
|
|
||||||
|
return ". ".join(explanations) + "."
|
||||||
|
|
||||||
|
def explain_move(self, fen_before: str, move_uci: str) -> str:
|
||||||
|
"""Explain why a specific move is good or bad."""
|
||||||
|
analysis_before = self.analyze_position(fen_before)
|
||||||
|
|
||||||
|
board = chess.Board(fen_before)
|
||||||
|
move = chess.Move.from_uci(move_uci)
|
||||||
|
san = board.san(move)
|
||||||
|
board.push(move)
|
||||||
|
|
||||||
|
analysis_after = self.analyze_position(board.fen())
|
||||||
|
|
||||||
|
eval_change = analysis_before.evaluation - analysis_after.evaluation
|
||||||
|
|
||||||
|
if move_uci == analysis_before.best_move:
|
||||||
|
quality = "This is the best move in this position!"
|
||||||
|
elif abs(eval_change) < 0.3:
|
||||||
|
quality = "A solid move that maintains the position."
|
||||||
|
elif eval_change > 1.0:
|
||||||
|
quality = f"This move loses about {eval_change:.1f} pawns of advantage."
|
||||||
|
elif eval_change > 0.3:
|
||||||
|
quality = "A slight inaccuracy - there was a better option."
|
||||||
|
else:
|
||||||
|
quality = "A good move!"
|
||||||
|
|
||||||
|
return f"{san}: {quality} {analysis_before.explanation}"
|
||||||
|
|
||||||
|
def get_hint(self, fen: str) -> str:
|
||||||
|
"""Get a hint for the current position."""
|
||||||
|
analysis = self.analyze_position(fen)
|
||||||
|
board = chess.Board(fen)
|
||||||
|
move = chess.Move.from_uci(analysis.best_move)
|
||||||
|
piece = board.piece_at(move.from_square)
|
||||||
|
|
||||||
|
if piece:
|
||||||
|
piece_name = chess.piece_name(piece.piece_type).capitalize()
|
||||||
|
return f"Consider moving your {piece_name}..."
|
||||||
|
return "Look for tactical opportunities..."
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Clean up engine resources."""
|
||||||
|
del self.engine
|
||||||
6
src/shellmate/core/__init__.py
Normal file
6
src/shellmate/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Core chess game logic."""
|
||||||
|
|
||||||
|
from .game import ChessGame
|
||||||
|
from .player import Player, PlayerType
|
||||||
|
|
||||||
|
__all__ = ["ChessGame", "Player", "PlayerType"]
|
||||||
125
src/shellmate/core/game.py
Normal file
125
src/shellmate/core/game.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Chess game engine wrapper."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
import chess
|
||||||
|
import chess.pgn
|
||||||
|
|
||||||
|
|
||||||
|
class GameResult(Enum):
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
WHITE_WINS = "white_wins"
|
||||||
|
BLACK_WINS = "black_wins"
|
||||||
|
DRAW = "draw"
|
||||||
|
ABORTED = "aborted"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Move:
|
||||||
|
"""Represents a chess move with metadata."""
|
||||||
|
uci: str
|
||||||
|
san: str
|
||||||
|
fen_before: str
|
||||||
|
fen_after: str
|
||||||
|
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
think_time_ms: int = 0
|
||||||
|
evaluation: Optional[float] = None
|
||||||
|
explanation: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChessGame:
|
||||||
|
"""Chess game instance."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
white_player_id: str
|
||||||
|
black_player_id: str
|
||||||
|
board: chess.Board = field(default_factory=chess.Board)
|
||||||
|
moves: list[Move] = field(default_factory=list)
|
||||||
|
result: GameResult = GameResult.IN_PROGRESS
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_turn(self) -> str:
|
||||||
|
"""Return whose turn it is."""
|
||||||
|
return "white" if self.board.turn == chess.WHITE else "black"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_player_id(self) -> str:
|
||||||
|
"""Return the ID of the player whose turn it is."""
|
||||||
|
return self.white_player_id if self.board.turn == chess.WHITE else self.black_player_id
|
||||||
|
|
||||||
|
def make_move(self, uci: str) -> Optional[Move]:
|
||||||
|
"""Make a move and return Move object if valid."""
|
||||||
|
try:
|
||||||
|
chess_move = chess.Move.from_uci(uci)
|
||||||
|
if chess_move not in self.board.legal_moves:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fen_before = self.board.fen()
|
||||||
|
san = self.board.san(chess_move)
|
||||||
|
self.board.push(chess_move)
|
||||||
|
fen_after = self.board.fen()
|
||||||
|
|
||||||
|
move = Move(
|
||||||
|
uci=uci,
|
||||||
|
san=san,
|
||||||
|
fen_before=fen_before,
|
||||||
|
fen_after=fen_after,
|
||||||
|
)
|
||||||
|
self.moves.append(move)
|
||||||
|
self._check_game_end()
|
||||||
|
return move
|
||||||
|
except (ValueError, chess.InvalidMoveError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_legal_moves(self) -> list[str]:
|
||||||
|
"""Return list of legal moves in UCI format."""
|
||||||
|
return [move.uci() for move in self.board.legal_moves]
|
||||||
|
|
||||||
|
def get_legal_moves_san(self) -> list[str]:
|
||||||
|
"""Return list of legal moves in SAN format."""
|
||||||
|
return [self.board.san(move) for move in self.board.legal_moves]
|
||||||
|
|
||||||
|
def _check_game_end(self) -> None:
|
||||||
|
"""Check if the game has ended and set result."""
|
||||||
|
if self.board.is_checkmate():
|
||||||
|
self.result = GameResult.BLACK_WINS if self.board.turn == chess.WHITE else GameResult.WHITE_WINS
|
||||||
|
elif self.board.is_stalemate() or self.board.is_insufficient_material():
|
||||||
|
self.result = GameResult.DRAW
|
||||||
|
elif self.board.can_claim_draw():
|
||||||
|
self.result = GameResult.DRAW
|
||||||
|
|
||||||
|
def is_check(self) -> bool:
|
||||||
|
"""Return True if current player is in check."""
|
||||||
|
return self.board.is_check()
|
||||||
|
|
||||||
|
def is_game_over(self) -> bool:
|
||||||
|
"""Return True if the game is over."""
|
||||||
|
return self.result != GameResult.IN_PROGRESS
|
||||||
|
|
||||||
|
def to_pgn(self) -> str:
|
||||||
|
"""Export game as PGN string."""
|
||||||
|
game = chess.pgn.Game()
|
||||||
|
game.headers["White"] = self.white_player_id
|
||||||
|
game.headers["Black"] = self.black_player_id
|
||||||
|
game.headers["Date"] = self.created_at.strftime("%Y.%m.%d")
|
||||||
|
|
||||||
|
node = game
|
||||||
|
temp_board = chess.Board()
|
||||||
|
for move in self.moves:
|
||||||
|
chess_move = chess.Move.from_uci(move.uci)
|
||||||
|
node = node.add_variation(chess_move)
|
||||||
|
temp_board.push(chess_move)
|
||||||
|
|
||||||
|
return str(game)
|
||||||
|
|
||||||
|
def get_board_display(self, perspective: str = "white") -> str:
|
||||||
|
"""Return ASCII board from given perspective."""
|
||||||
|
board_str = str(self.board)
|
||||||
|
if perspective == "black":
|
||||||
|
lines = board_str.split('\n')
|
||||||
|
board_str = '\n'.join(reversed(lines))
|
||||||
|
return board_str
|
||||||
68
src/shellmate/core/player.py
Normal file
68
src/shellmate/core/player.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Player models."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerType(Enum):
|
||||||
|
HUMAN = "human"
|
||||||
|
AI = "ai"
|
||||||
|
GUEST = "guest"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Player:
|
||||||
|
"""Represents a player."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
player_type: PlayerType = PlayerType.HUMAN
|
||||||
|
elo: int = 1200
|
||||||
|
games_played: int = 0
|
||||||
|
wins: int = 0
|
||||||
|
losses: int = 0
|
||||||
|
draws: int = 0
|
||||||
|
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
last_seen: Optional[datetime] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def winrate(self) -> float:
|
||||||
|
"""Calculate win rate percentage."""
|
||||||
|
if self.games_played == 0:
|
||||||
|
return 0.0
|
||||||
|
return (self.wins / self.games_played) * 100
|
||||||
|
|
||||||
|
def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int:
|
||||||
|
"""
|
||||||
|
Update ELO rating based on game result.
|
||||||
|
result: 1.0 for win, 0.5 for draw, 0.0 for loss
|
||||||
|
Returns the ELO change.
|
||||||
|
"""
|
||||||
|
expected = 1 / (1 + 10 ** ((opponent_elo - self.elo) / 400))
|
||||||
|
change = int(k * (result - expected))
|
||||||
|
self.elo += change
|
||||||
|
return change
|
||||||
|
|
||||||
|
def record_game(self, won: bool, draw: bool = False) -> None:
|
||||||
|
"""Record a completed game."""
|
||||||
|
self.games_played += 1
|
||||||
|
if draw:
|
||||||
|
self.draws += 1
|
||||||
|
elif won:
|
||||||
|
self.wins += 1
|
||||||
|
else:
|
||||||
|
self.losses += 1
|
||||||
|
self.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIPlayer(Player):
|
||||||
|
"""AI player with configurable difficulty."""
|
||||||
|
|
||||||
|
difficulty: int = 10 # Stockfish skill level 0-20
|
||||||
|
think_time_ms: int = 1000 # Time to think per move
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.player_type = PlayerType.AI
|
||||||
0
src/shellmate/learn/__init__.py
Normal file
0
src/shellmate/learn/__init__.py
Normal file
0
src/shellmate/multiplayer/__init__.py
Normal file
0
src/shellmate/multiplayer/__init__.py
Normal file
5
src/shellmate/ssh/__init__.py
Normal file
5
src/shellmate/ssh/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""SSH server for ShellMate."""
|
||||||
|
|
||||||
|
from .server import ShellMateSSHServer
|
||||||
|
|
||||||
|
__all__ = ["ShellMateSSHServer"]
|
||||||
118
src/shellmate/ssh/server.py
Normal file
118
src/shellmate/ssh/server.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""SSH server implementation."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
import asyncssh
|
||||||
|
|
||||||
|
from shellmate.tui.app import ShellMateApp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ShellMateSSHServer(asyncssh.SSHServer):
|
||||||
|
"""SSH server that launches ShellMate TUI for each connection."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._username: Optional[str] = None
|
||||||
|
|
||||||
|
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
|
||||||
|
logger.info(f"SSH connection from {conn.get_extra_info('peername')}")
|
||||||
|
|
||||||
|
def connection_lost(self, exc: Optional[Exception]) -> None:
|
||||||
|
if exc:
|
||||||
|
logger.error(f"SSH connection error: {exc}")
|
||||||
|
else:
|
||||||
|
logger.info("SSH connection closed")
|
||||||
|
|
||||||
|
def begin_auth(self, username: str) -> bool:
|
||||||
|
self._username = username
|
||||||
|
# Allow all usernames, we'll handle auth in the app
|
||||||
|
return True
|
||||||
|
|
||||||
|
def password_auth_supported(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_password(self, username: str, password: str) -> bool:
|
||||||
|
# Guest access - accept any password or empty
|
||||||
|
# For registered users, validate against database
|
||||||
|
return True # TODO: Implement proper auth
|
||||||
|
|
||||||
|
def public_key_auth_supported(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
|
||||||
|
# Accept any public key for now
|
||||||
|
return True # TODO: Implement key-based auth
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_client(process: asyncssh.SSHServerProcess) -> None:
|
||||||
|
"""Handle an SSH client session."""
|
||||||
|
username = process.get_extra_info("username") or "guest"
|
||||||
|
|
||||||
|
# Determine mode based on username
|
||||||
|
if username == "learn":
|
||||||
|
mode = "tutorial"
|
||||||
|
elif username == "watch":
|
||||||
|
mode = "spectate"
|
||||||
|
else:
|
||||||
|
mode = "play"
|
||||||
|
|
||||||
|
logger.info(f"Starting ShellMate for {username} in {mode} mode")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create and run the TUI app
|
||||||
|
app = ShellMateApp(
|
||||||
|
username=username,
|
||||||
|
mode=mode,
|
||||||
|
stdin=process.stdin,
|
||||||
|
stdout=process.stdout,
|
||||||
|
)
|
||||||
|
await app.run_async()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in ShellMate session: {e}")
|
||||||
|
process.stdout.write(f"\r\nError: {e}\r\n")
|
||||||
|
finally:
|
||||||
|
process.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_server(
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = 22,
|
||||||
|
host_keys: list[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Start the SSH server."""
|
||||||
|
host_keys = host_keys or ["/etc/shellmate/ssh_host_key"]
|
||||||
|
|
||||||
|
logger.info(f"Starting ShellMate SSH server on {host}:{port}")
|
||||||
|
|
||||||
|
await asyncssh.create_server(
|
||||||
|
ShellMateSSHServer,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
server_host_keys=host_keys,
|
||||||
|
process_factory=handle_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entry point for the SSH server."""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(start_server())
|
||||||
|
loop.run_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
src/shellmate/tui/__init__.py
Normal file
5
src/shellmate/tui/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""TUI components for ShellMate."""
|
||||||
|
|
||||||
|
from .app import ShellMateApp
|
||||||
|
|
||||||
|
__all__ = ["ShellMateApp"]
|
||||||
182
src/shellmate/tui/app.py
Normal file
182
src/shellmate/tui/app.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Main TUI application."""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
|
from textual.widgets import Button, Footer, Header, Static, Label
|
||||||
|
from textual.binding import Binding
|
||||||
|
|
||||||
|
from shellmate.tui.widgets.board import ChessBoard
|
||||||
|
from shellmate.tui.widgets.move_list import MoveList
|
||||||
|
from shellmate.tui.widgets.status import GameStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ShellMateApp(App):
|
||||||
|
"""ShellMate TUI Chess Application."""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
layout: grid;
|
||||||
|
grid-size: 2;
|
||||||
|
grid-columns: 3fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#board-container {
|
||||||
|
height: 100%;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
height: 100%;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChessBoard {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#move-list {
|
||||||
|
height: 1fr;
|
||||||
|
border: solid green;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game-status {
|
||||||
|
height: auto;
|
||||||
|
padding: 1;
|
||||||
|
border: solid blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
height: auto;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("q", "quit", "Quit"),
|
||||||
|
Binding("n", "new_game", "New Game"),
|
||||||
|
Binding("h", "hint", "Hint"),
|
||||||
|
Binding("u", "undo", "Undo"),
|
||||||
|
Binding("r", "resign", "Resign"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username: str = "guest",
|
||||||
|
mode: str = "play",
|
||||||
|
stdin: Any = None,
|
||||||
|
stdout: Any = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.username = username
|
||||||
|
self.mode = mode
|
||||||
|
self._stdin = stdin
|
||||||
|
self._stdout = stdout
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
|
||||||
|
with Horizontal():
|
||||||
|
with Container(id="board-container"):
|
||||||
|
yield ChessBoard(id="chess-board")
|
||||||
|
|
||||||
|
with Vertical(id="sidebar"):
|
||||||
|
yield GameStatus(id="game-status")
|
||||||
|
yield MoveList(id="move-list")
|
||||||
|
|
||||||
|
with Container(id="controls"):
|
||||||
|
yield Button("New Game", id="btn-new", classes="menu-button")
|
||||||
|
yield Button("vs AI", id="btn-ai", classes="menu-button")
|
||||||
|
yield Button("vs Player", id="btn-pvp", classes="menu-button")
|
||||||
|
yield Button("Tutorial", id="btn-learn", classes="menu-button")
|
||||||
|
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Called when app is mounted."""
|
||||||
|
self.title = "♟️ ShellMate"
|
||||||
|
self.sub_title = f"Welcome, {self.username}!"
|
||||||
|
|
||||||
|
def action_new_game(self) -> None:
|
||||||
|
"""Start a new game."""
|
||||||
|
board = self.query_one("#chess-board", ChessBoard)
|
||||||
|
board.new_game()
|
||||||
|
self.notify("New game started!")
|
||||||
|
|
||||||
|
def action_hint(self) -> None:
|
||||||
|
"""Get a hint from the AI."""
|
||||||
|
self.notify("Hint: Consider developing your pieces...")
|
||||||
|
|
||||||
|
def action_undo(self) -> None:
|
||||||
|
"""Undo the last move."""
|
||||||
|
board = self.query_one("#chess-board", ChessBoard)
|
||||||
|
if board.undo_move():
|
||||||
|
self.notify("Move undone")
|
||||||
|
else:
|
||||||
|
self.notify("No moves to undo")
|
||||||
|
|
||||||
|
def action_resign(self) -> None:
|
||||||
|
"""Resign the current game."""
|
||||||
|
self.notify("Game resigned")
|
||||||
|
|
||||||
|
|
||||||
|
# Placeholder widgets - to be implemented
|
||||||
|
class ChessBoard(Static):
|
||||||
|
"""Chess board widget."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._board_str = self._get_initial_board()
|
||||||
|
|
||||||
|
def _get_initial_board(self) -> str:
|
||||||
|
return """
|
||||||
|
a b c d e f g h
|
||||||
|
┌───┬───┬───┬───┬───┬───┬───┬───┐
|
||||||
|
8 │ ♜ │ ♞ │ ♝ │ ♛ │ ♚ │ ♝ │ ♞ │ ♜ │ 8
|
||||||
|
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
7 │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ 7
|
||||||
|
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
6 │ │ │ │ │ │ │ │ │ 6
|
||||||
|
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
5 │ │ │ │ │ │ │ │ │ 5
|
||||||
|
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
4 │ │ │ │ │ │ │ │ │ 4
|
||||||
|
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
3 │ │ │ │ │ │ │ │ │ 3
|
||||||
|
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
2 │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ 2
|
||||||
|
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||||
|
1 │ ♖ │ ♘ │ ♗ │ ♕ │ ♔ │ ♗ │ ♘ │ ♖ │ 1
|
||||||
|
└───┴───┴───┴───┴───┴───┴───┴───┘
|
||||||
|
a b c d e f g h
|
||||||
|
"""
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
return self._board_str
|
||||||
|
|
||||||
|
def new_game(self) -> None:
|
||||||
|
self._board_str = self._get_initial_board()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def undo_move(self) -> bool:
|
||||||
|
return False # TODO: Implement
|
||||||
|
|
||||||
|
|
||||||
|
class MoveList(Static):
|
||||||
|
"""Move history widget."""
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
return "Move History\n─────────────\n1. e4 e5\n2. Nf3 Nc6\n3. ..."
|
||||||
|
|
||||||
|
|
||||||
|
class GameStatus(Static):
|
||||||
|
"""Game status widget."""
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
return "⚪ White to move\n\n🕐 10:00 | 10:00"
|
||||||
0
src/shellmate/tui/widgets/__init__.py
Normal file
0
src/shellmate/tui/widgets/__init__.py
Normal file
Reference in New Issue
Block a user