mirror of
https://github.com/imjasonh/chessh
synced 2026-07-07 00:33:53 +00:00
602 lines
15 KiB
Go
602 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
secretmanager "cloud.google.com/go/secretmanager/apiv1"
|
|
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/ssh"
|
|
"github.com/charmbracelet/wish"
|
|
"github.com/charmbracelet/wish/bubbletea"
|
|
"github.com/charmbracelet/wish/logging"
|
|
)
|
|
|
|
type model struct {
|
|
// Game state
|
|
game *Game
|
|
cursorRow int
|
|
cursorCol int
|
|
selected *Position
|
|
validMoves []Position
|
|
|
|
// Multiplayer state
|
|
player *Player
|
|
opponent *Player
|
|
gameSession *GameSession
|
|
gameState string // "waiting", "playing", "finished", "opponent_disconnected"
|
|
isMyTurn bool
|
|
}
|
|
|
|
func initialModel() model {
|
|
return model{
|
|
game: NewGame(),
|
|
cursorRow: 0,
|
|
cursorCol: 0,
|
|
selected: nil,
|
|
validMoves: make([]Position, 0),
|
|
gameState: "waiting",
|
|
isMyTurn: false,
|
|
}
|
|
}
|
|
|
|
func initialModelWithPlayer(player *Player) model {
|
|
m := initialModel()
|
|
m.player = player
|
|
m.gameState = "waiting"
|
|
return m
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
if m.player != nil && m.player.UpdateChan != nil {
|
|
return m.listenForUpdates()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m model) listenForUpdates() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if m.player != nil && m.player.UpdateChan != nil {
|
|
return <-m.player.UpdateChan
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
// Global keys
|
|
switch msg.Type {
|
|
case tea.KeyCtrlC:
|
|
return m, tea.Quit
|
|
case tea.KeyEscape:
|
|
if m.gameState == "playing" && m.isMyTurn {
|
|
m.selected = nil
|
|
m.validMoves = make([]Position, 0)
|
|
// Broadcast deselection to opponent
|
|
if m.gameSession != nil {
|
|
GetGameManager().BroadcastUpdate(m.player.ID, GameUpdate{
|
|
Type: "deselect",
|
|
Data: nil,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "q":
|
|
return m, tea.Quit
|
|
}
|
|
|
|
// Only handle game input if it's the player's turn and game is active
|
|
if m.gameState == "playing" && m.isMyTurn {
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.cursorRow < 7 {
|
|
m.cursorRow++
|
|
m.broadcastCursorUpdate()
|
|
}
|
|
case "down", "j":
|
|
if m.cursorRow > 0 {
|
|
m.cursorRow--
|
|
m.broadcastCursorUpdate()
|
|
}
|
|
case "left", "h":
|
|
if m.cursorCol > 0 {
|
|
m.cursorCol--
|
|
m.broadcastCursorUpdate()
|
|
}
|
|
case "right", "l":
|
|
if m.cursorCol < 7 {
|
|
m.cursorCol++
|
|
m.broadcastCursorUpdate()
|
|
}
|
|
case "enter", " ":
|
|
currentPos := Position{m.cursorRow, m.cursorCol}
|
|
|
|
if m.selected == nil {
|
|
piece := m.game.Board.At(currentPos)
|
|
if piece.Type != Empty && piece.Color == m.player.Color {
|
|
m.selected = ¤tPos
|
|
m.validMoves = m.getValidMoves(currentPos)
|
|
// Broadcast selection
|
|
GetGameManager().BroadcastUpdate(m.player.ID, GameUpdate{
|
|
Type: "select",
|
|
Data: map[string]interface{}{
|
|
"position": currentPos,
|
|
"validMoves": m.validMoves,
|
|
},
|
|
})
|
|
}
|
|
} else {
|
|
if *m.selected == currentPos {
|
|
m.selected = nil
|
|
m.validMoves = make([]Position, 0)
|
|
// Broadcast deselection
|
|
GetGameManager().BroadcastUpdate(m.player.ID, GameUpdate{
|
|
Type: "deselect",
|
|
Data: nil,
|
|
})
|
|
} else if m.game.MakeMove(*m.selected, currentPos) {
|
|
// Move successful - broadcast to opponent
|
|
GetGameManager().BroadcastUpdate(m.player.ID, GameUpdate{
|
|
Type: "move",
|
|
Data: map[string]interface{}{
|
|
"from": *m.selected,
|
|
"to": currentPos,
|
|
"gameState": m.game,
|
|
},
|
|
})
|
|
m.selected = nil
|
|
m.validMoves = make([]Position, 0)
|
|
m.isMyTurn = false
|
|
}
|
|
}
|
|
}
|
|
} else if m.gameState == "waiting" || m.gameState == "opponent_disconnected" {
|
|
// In waiting mode or after opponent disconnect, allow basic navigation for UI exploration but no moves
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.cursorRow < 7 {
|
|
m.cursorRow++
|
|
}
|
|
case "down", "j":
|
|
if m.cursorRow > 0 {
|
|
m.cursorRow--
|
|
}
|
|
case "left", "h":
|
|
if m.cursorCol > 0 {
|
|
m.cursorCol--
|
|
}
|
|
case "right", "l":
|
|
if m.cursorCol < 7 {
|
|
m.cursorCol++
|
|
}
|
|
}
|
|
}
|
|
|
|
case GameUpdate:
|
|
return m.handleGameUpdate(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) broadcastCursorUpdate() {
|
|
if m.gameSession != nil {
|
|
GetGameManager().BroadcastUpdate(m.player.ID, GameUpdate{
|
|
Type: "cursor",
|
|
Data: map[string]interface{}{
|
|
"row": m.cursorRow,
|
|
"col": m.cursorCol,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
func (m model) handleGameUpdate(update GameUpdate) (tea.Model, tea.Cmd) {
|
|
// Don't process updates from self
|
|
if m.player != nil && update.FromPlayer == m.player.ID {
|
|
return m, m.listenForUpdates()
|
|
}
|
|
|
|
switch update.Type {
|
|
case "matched":
|
|
m.gameState = "playing"
|
|
m.gameSession = GetGameManager().GetGameSession(m.player.ID)
|
|
if m.gameSession != nil {
|
|
m.game = m.gameSession.Game
|
|
m.opponent = m.gameSession.GetOpponent(m.player.ID)
|
|
m.isMyTurn = (m.player.Color == White) // White goes first
|
|
}
|
|
|
|
case "move":
|
|
if data, ok := update.Data.(map[string]interface{}); ok {
|
|
// Update game state from opponent's move
|
|
if gameState, ok := data["gameState"].(*Game); ok {
|
|
m.game = gameState
|
|
m.isMyTurn = true // It's now our turn
|
|
}
|
|
}
|
|
|
|
case "cursor":
|
|
// Opponent cursor movement - we could show this in UI later
|
|
|
|
case "select":
|
|
// Opponent piece selection - we could show this in UI later
|
|
|
|
case "deselect":
|
|
// Opponent deselected - clear any opponent indicators
|
|
|
|
case "opponent_disconnected":
|
|
m.gameState = "opponent_disconnected"
|
|
m.isMyTurn = false // Disable input
|
|
}
|
|
|
|
// Continue listening for updates
|
|
return m, m.listenForUpdates()
|
|
}
|
|
|
|
func (m model) getValidMoves(from Position) []Position {
|
|
var moves []Position
|
|
|
|
for row := range 8 {
|
|
for col := range 8 {
|
|
to := Position{row, col}
|
|
if m.game.IsValidMove(from, to) {
|
|
moves = append(moves, to)
|
|
}
|
|
}
|
|
}
|
|
|
|
return moves
|
|
}
|
|
|
|
func (m model) View() string {
|
|
var s strings.Builder
|
|
|
|
if m.gameState == "waiting" {
|
|
s.WriteString("CheSSH\n")
|
|
s.WriteString("Waiting for an opponent to connect...\n\n")
|
|
|
|
// Show queue position if available
|
|
if m.player != nil {
|
|
position := GetGameManager().GetQueuePosition(m.player.ID)
|
|
if position > 0 {
|
|
s.WriteString(fmt.Sprintf("Position in queue: %d\n", position))
|
|
}
|
|
}
|
|
|
|
s.WriteString("You can explore the board while waiting:\n")
|
|
s.WriteString("Use arrow keys to move cursor, Q to quit\n\n")
|
|
s.WriteString(m.renderBoardWithInfo())
|
|
return s.String()
|
|
}
|
|
|
|
if m.gameState == "opponent_disconnected" {
|
|
s.WriteString("CheSSH\n")
|
|
s.WriteString("*** OPPONENT DISCONNECTED; YOU WIN ***\n\n")
|
|
s.WriteString("Your opponent has left the game.\n")
|
|
s.WriteString("You can continue exploring the board or press Q to quit.\n\n")
|
|
s.WriteString(m.renderBoardWithInfo())
|
|
return s.String()
|
|
}
|
|
|
|
s.WriteString("CheSSH\n")
|
|
|
|
if m.player != nil && m.opponent != nil {
|
|
s.WriteString(fmt.Sprintf("You: %s (%s) vs %s (%s)\n",
|
|
m.player.Name, m.player.Color, m.opponent.Name, m.opponent.Color))
|
|
}
|
|
|
|
if m.isMyTurn {
|
|
s.WriteString("YOUR TURN - Use arrow keys to move cursor\n")
|
|
s.WriteString("SPACE to select, ESC to deselect, Q to quit\n\n")
|
|
} else {
|
|
s.WriteString("OPPONENT'S TURN - Please wait\n\n\n")
|
|
}
|
|
|
|
status := m.game.GameStatus()
|
|
if status != "" {
|
|
s.WriteString(fmt.Sprintf("*** %s ***\n\n", status))
|
|
}
|
|
|
|
s.WriteString(m.renderBoardWithInfo())
|
|
|
|
return s.String()
|
|
}
|
|
|
|
func (m model) renderBoardWithInfo() string {
|
|
boardLines := m.getBoardLines()
|
|
infoLines := m.getInfoLines()
|
|
|
|
var s strings.Builder
|
|
maxLines := len(boardLines)
|
|
if len(infoLines) > maxLines {
|
|
maxLines = len(infoLines)
|
|
}
|
|
|
|
for i := 0; i < maxLines; i++ {
|
|
if i < len(boardLines) {
|
|
s.WriteString(boardLines[i])
|
|
} else {
|
|
s.WriteString(strings.Repeat(" ", 26)) // Board width padding (8*3 + 2 for row numbers)
|
|
}
|
|
|
|
s.WriteString(" ")
|
|
|
|
if i < len(infoLines) {
|
|
s.WriteString(infoLines[i])
|
|
}
|
|
|
|
s.WriteString("\n")
|
|
}
|
|
|
|
return s.String()
|
|
}
|
|
|
|
func (m model) getBoardLines() []string {
|
|
var lines []string
|
|
|
|
lines = append(lines, " a b c d e f g h ")
|
|
|
|
for row := 7; row >= 0; row-- {
|
|
var line strings.Builder
|
|
line.WriteString(fmt.Sprintf("%d", row+1))
|
|
|
|
for col := range 8 {
|
|
pos := Position{row, col}
|
|
piece := m.game.Board.At(pos)
|
|
|
|
cellChar := piece.String()
|
|
if piece.Type == Empty {
|
|
cellChar = " "
|
|
}
|
|
|
|
// Determine background color
|
|
var bgColor string
|
|
if m.cursorRow == row && m.cursorCol == col {
|
|
bgColor = "\033[41m" // Red background for cursor
|
|
} else if m.selected != nil && m.selected.Row == row && m.selected.Col == col {
|
|
bgColor = "\033[43m" // Yellow background for selected
|
|
} else if slices.Contains(m.validMoves, pos) {
|
|
bgColor = "\033[42m" // Green background for valid moves
|
|
} else if (row+col)%2 == 0 {
|
|
bgColor = "\033[100m" // Light grey background for light squares
|
|
} else {
|
|
bgColor = "\033[40m" // Black background for dark squares
|
|
}
|
|
|
|
line.WriteString(fmt.Sprintf("%s %s \033[0m", bgColor, cellChar))
|
|
}
|
|
|
|
line.WriteString(fmt.Sprintf("%d", row+1))
|
|
lines = append(lines, line.String())
|
|
}
|
|
|
|
lines = append(lines, " a b c d e f g h ")
|
|
|
|
return lines
|
|
}
|
|
|
|
func (m model) getInfoLines() []string {
|
|
var lines []string
|
|
|
|
lines = append(lines, "┌─────────────────────┐")
|
|
lines = append(lines, "│ GAME INFO │")
|
|
lines = append(lines, "├─────────────────────┤")
|
|
lines = append(lines, fmt.Sprintf("│ Turn: %-13s │", m.game.CurrentTurn))
|
|
lines = append(lines, "│ │")
|
|
|
|
cursorPos := Position{m.cursorRow, m.cursorCol}
|
|
piece := m.game.Board.At(cursorPos)
|
|
lines = append(lines, fmt.Sprintf("│ Cursor: %-11s │", cursorPos.String()))
|
|
|
|
if piece.Type == Empty {
|
|
lines = append(lines, "│ Piece: Empty │")
|
|
} else {
|
|
pieceName := m.getPieceName(piece)
|
|
lines = append(lines, fmt.Sprintf("│ Piece: %-12s │", pieceName))
|
|
}
|
|
|
|
lines = append(lines, "│ │")
|
|
|
|
lines = append(lines, "└─────────────────────┘")
|
|
|
|
if len(m.game.MoveHistory) > 0 {
|
|
lastMove := m.game.MoveHistory[len(m.game.MoveHistory)-1]
|
|
lines = append(lines, fmt.Sprintf("Last move: %s -> %-12s", lastMove.From.String(), lastMove.To.String()))
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
func (m model) getPieceName(piece Piece) string {
|
|
if piece.Type == Empty {
|
|
return "Empty"
|
|
}
|
|
|
|
color := "White"
|
|
if piece.Color == Black {
|
|
color = "Black"
|
|
}
|
|
|
|
pieceType := ""
|
|
switch piece.Type {
|
|
case Pawn:
|
|
pieceType = "Pawn"
|
|
case Rook:
|
|
pieceType = "Rook"
|
|
case Knight:
|
|
pieceType = "Knight"
|
|
case Bishop:
|
|
pieceType = "Bishop"
|
|
case Queen:
|
|
pieceType = "Queen"
|
|
case King:
|
|
pieceType = "King"
|
|
}
|
|
|
|
return fmt.Sprintf("%s %s", color, pieceType)
|
|
}
|
|
|
|
// generateOrLoadHostKey generates a new ED25519 host key or loads existing one
|
|
func generateOrLoadHostKey(keyPath string) ([]byte, error) {
|
|
// Try to read existing key first
|
|
if keyData, err := os.ReadFile(keyPath); err == nil {
|
|
return keyData, nil
|
|
}
|
|
|
|
// Generate new key
|
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate ED25519 key: %w", err)
|
|
}
|
|
|
|
// Convert to PKCS#8 format
|
|
pkcs8Key, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal private key: %w", err)
|
|
}
|
|
|
|
// Encode as PEM
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Bytes: pkcs8Key,
|
|
})
|
|
|
|
// Create directory if it doesn't exist
|
|
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
|
|
return nil, fmt.Errorf("failed to create key directory: %w", err)
|
|
}
|
|
|
|
// Save key to file
|
|
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
|
return nil, fmt.Errorf("failed to save host key: %w", err)
|
|
}
|
|
|
|
log.Printf("Generated new SSH host key: %s", keyPath)
|
|
return keyPEM, nil
|
|
}
|
|
|
|
func main() {
|
|
var (
|
|
sshPort = flag.Int("port", 2222, "SSH server port")
|
|
local = flag.Bool("local", false, "run in local mode (generates/uses local host key instead of Secret Manager)")
|
|
)
|
|
flag.Parse()
|
|
|
|
var hostKeyData []byte
|
|
var err error
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
if *local {
|
|
// Local mode: generate or load local host key
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
log.Fatalf("failed to get user home directory: %v", err)
|
|
}
|
|
keyPath := filepath.Join(homeDir, ".chessh", "host_key")
|
|
hostKeyData, err = generateOrLoadHostKey(keyPath)
|
|
if err != nil {
|
|
log.Fatalf("failed to generate/load host key: %v", err)
|
|
}
|
|
log.Println("Running in local mode")
|
|
} else {
|
|
// Cloud mode: use Secret Manager
|
|
client, err := secretmanager.NewClient(ctx)
|
|
if err != nil {
|
|
log.Fatalf("failed to create Secret Manager client: %v", err)
|
|
}
|
|
defer client.Close()
|
|
resp, err := client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
|
|
Name: os.Getenv("SSH_HOST_KEY_SECRET"),
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("failed to access secret version: %v", err)
|
|
}
|
|
hostKeyData = resp.Payload.Data
|
|
log.Println("Running in cloud mode with Secret Manager")
|
|
}
|
|
|
|
s, err := wish.NewServer(
|
|
wish.WithAddress(fmt.Sprintf(":%d", *sshPort)),
|
|
wish.WithHostKeyPEM(hostKeyData),
|
|
wish.WithMiddleware(
|
|
bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
|
// Create player from SSH session
|
|
player := &Player{
|
|
ID: fmt.Sprintf("player_%d", time.Now().UnixNano()),
|
|
Session: s,
|
|
Name: s.User(),
|
|
Connected: true,
|
|
UpdateChan: make(chan GameUpdate, 10),
|
|
}
|
|
|
|
// Create model with player
|
|
m := initialModelWithPlayer(player)
|
|
|
|
// Add player to matchmaking queue first
|
|
GetGameManager().AddPlayer(player)
|
|
|
|
// Handle cleanup on session end
|
|
go func() {
|
|
<-s.Context().Done()
|
|
GetGameManager().RemovePlayer(player.ID)
|
|
if player.UpdateChan != nil {
|
|
close(player.UpdateChan)
|
|
}
|
|
}()
|
|
|
|
return m, []tea.ProgramOption{tea.WithAltScreen(), tea.WithInput(s), tea.WithOutput(s)}
|
|
}),
|
|
logging.Middleware(),
|
|
),
|
|
)
|
|
if err != nil {
|
|
log.Fatalln(err)
|
|
}
|
|
go func() {
|
|
log.Printf("Starting SSH chess server on :%d", *sshPort)
|
|
if err = s.ListenAndServe(); err != nil {
|
|
log.Fatalln(err)
|
|
}
|
|
}()
|
|
|
|
if httpPort := os.Getenv("PORT"); httpPort != "" {
|
|
log.Print("Starting HTTP health check server on port ", httpPort)
|
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK"))
|
|
})
|
|
if err := http.ListenAndServe(fmt.Sprintf(":%s", httpPort), nil); err != nil {
|
|
log.Fatalln("HTTP server error:", err)
|
|
}
|
|
}
|
|
|
|
<-ctx.Done()
|
|
log.Println("Stopping SSH server")
|
|
|
|
tctx, tcancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
|
defer tcancel()
|
|
if err := s.Shutdown(tctx); err != nil {
|
|
log.Fatalln(err)
|
|
}
|
|
}
|