1
0
Fork 0
mirror of https://github.com/imjasonh/chessh synced 2026-07-07 00:33:53 +00:00
chessh/multiplayer.go
Jason Hall 65e813fb4d multiplayer, add readme, claude and license
Signed-off-by: Jason Hall <jason@chainguard.dev>
2025-09-12 20:17:39 -04:00

311 lines
6.8 KiB
Go

package main
import (
"context"
"fmt"
"sync"
"time"
"github.com/charmbracelet/ssh"
)
// Player represents a connected player
type Player struct {
ID string
Session ssh.Session
Color Color
Name string
GameID string
Connected bool
UpdateChan chan GameUpdate // Channel for sending updates to the player's model
}
// GameUpdate represents an update to broadcast to players
type GameUpdate struct {
Type string // "move", "cursor", "select", "gamestate"
Data interface{} // The actual update data
FromPlayer string // Which player sent the update
}
// GameSession manages a single game between two players
type GameSession struct {
ID string
Game *Game
White *Player
Black *Player
Updates chan GameUpdate
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
}
func NewGameSession(id string, white, black *Player) *GameSession {
ctx, cancel := context.WithCancel(context.Background())
session := &GameSession{
ID: id,
Game: NewGame(),
White: white,
Black: black,
Updates: make(chan GameUpdate, 10),
ctx: ctx,
cancel: cancel,
}
// Assign colors and game ID to players
white.Color = White
white.GameID = id
black.Color = Black
black.GameID = id
// Start the update broadcaster
go session.handleUpdates()
return session
}
func (gs *GameSession) handleUpdates() {
for {
select {
case <-gs.ctx.Done():
return
case update := <-gs.Updates:
gs.broadcastUpdate(update)
}
}
}
func (gs *GameSession) broadcastUpdate(update GameUpdate) {
gs.mu.RLock()
defer gs.mu.RUnlock()
// Send update to both players (if connected) via their update channels
if gs.White != nil && gs.White.Connected && gs.White.UpdateChan != nil {
select {
case gs.White.UpdateChan <- update:
default:
// Channel full, drop update
}
}
if gs.Black != nil && gs.Black.Connected && gs.Black.UpdateChan != nil {
select {
case gs.Black.UpdateChan <- update:
default:
// Channel full, drop update
}
}
}
func (gs *GameSession) GetPlayer(playerID string) *Player {
gs.mu.RLock()
defer gs.mu.RUnlock()
if gs.White != nil && gs.White.ID == playerID {
return gs.White
}
if gs.Black != nil && gs.Black.ID == playerID {
return gs.Black
}
return nil
}
func (gs *GameSession) GetOpponent(playerID string) *Player {
gs.mu.RLock()
defer gs.mu.RUnlock()
if gs.White != nil && gs.White.ID == playerID {
return gs.Black
}
if gs.Black != nil && gs.Black.ID == playerID {
return gs.White
}
return nil
}
func (gs *GameSession) IsPlayerTurn(playerID string) bool {
player := gs.GetPlayer(playerID)
if player == nil {
return false
}
return gs.Game.CurrentTurn == player.Color
}
func (gs *GameSession) Disconnect(playerID string) {
gs.mu.Lock()
defer gs.mu.Unlock()
var disconnectedPlayer, remainingPlayer *Player
if gs.White != nil && gs.White.ID == playerID {
gs.White.Connected = false
disconnectedPlayer = gs.White
remainingPlayer = gs.Black
}
if gs.Black != nil && gs.Black.ID == playerID {
gs.Black.Connected = false
disconnectedPlayer = gs.Black
remainingPlayer = gs.White
}
// Notify remaining player of opponent disconnect
if remainingPlayer != nil && remainingPlayer.Connected && remainingPlayer.UpdateChan != nil {
disconnectUpdate := GameUpdate{
Type: "opponent_disconnected",
Data: map[string]any{
"disconnectedPlayer": disconnectedPlayer.Name,
},
}
select {
case remainingPlayer.UpdateChan <- disconnectUpdate:
default:
}
}
// If both players disconnected, cleanup
if (gs.White == nil || !gs.White.Connected) && (gs.Black == nil || !gs.Black.Connected) {
gs.cleanup()
}
}
func (gs *GameSession) cleanup() {
gs.cancel()
close(gs.Updates)
}
// GameManager handles matchmaking and game coordination
type GameManager struct {
playerQueue []*Player
activeGames map[string]*GameSession
playerToGame map[string]string // playerID -> gameID
mu sync.RWMutex
gameCounter int
}
var gameManager *GameManager
var gameManagerOnce sync.Once
func GetGameManager() *GameManager {
gameManagerOnce.Do(func() {
gameManager = &GameManager{
playerQueue: make([]*Player, 0),
activeGames: make(map[string]*GameSession),
playerToGame: make(map[string]string),
}
})
return gameManager
}
func (gm *GameManager) AddPlayer(player *Player) {
gm.mu.Lock()
defer gm.mu.Unlock()
// Add to queue
gm.playerQueue = append(gm.playerQueue, player)
// Try to match with another player
if len(gm.playerQueue) >= 2 {
white := gm.playerQueue[0]
black := gm.playerQueue[1]
// Remove from queue
gm.playerQueue = gm.playerQueue[2:]
// Create game
gm.gameCounter++
gameID := fmt.Sprintf("game_%d", gm.gameCounter)
session := NewGameSession(gameID, white, black)
gm.activeGames[gameID] = session
gm.playerToGame[white.ID] = gameID
gm.playerToGame[black.ID] = gameID
// Notify players they've been matched
matchUpdate := GameUpdate{
Type: "matched",
Data: map[string]any{
"gameID": gameID,
"opponent": map[string]string{
"white_opponent": black.Name,
"black_opponent": white.Name,
},
},
}
// Send match update to both players via their channels
if white.UpdateChan != nil {
select {
case white.UpdateChan <- matchUpdate:
default:
}
}
if black.UpdateChan != nil {
select {
case black.UpdateChan <- matchUpdate:
default:
}
}
}
}
func (gm *GameManager) RemovePlayer(playerID string) {
gm.mu.Lock()
defer gm.mu.Unlock()
// Remove from queue if present
for i, player := range gm.playerQueue {
if player.ID == playerID {
gm.playerQueue = append(gm.playerQueue[:i], gm.playerQueue[i+1:]...)
break
}
}
// Handle active game disconnection
if gameID, exists := gm.playerToGame[playerID]; exists {
if session, gameExists := gm.activeGames[gameID]; gameExists {
session.Disconnect(playerID)
// Clean up if game is over
if (session.White == nil || !session.White.Connected) &&
(session.Black == nil || !session.Black.Connected) {
delete(gm.activeGames, gameID)
delete(gm.playerToGame, session.White.ID)
delete(gm.playerToGame, session.Black.ID)
}
}
delete(gm.playerToGame, playerID)
}
}
func (gm *GameManager) GetGameSession(playerID string) *GameSession {
gm.mu.RLock()
defer gm.mu.RUnlock()
if gameID, exists := gm.playerToGame[playerID]; exists {
return gm.activeGames[gameID]
}
return nil
}
func (gm *GameManager) BroadcastUpdate(playerID string, update GameUpdate) {
session := gm.GetGameSession(playerID)
if session != nil {
update.FromPlayer = playerID
select {
case session.Updates <- update:
case <-time.After(100 * time.Millisecond):
// Drop update if channel is full
}
}
}
func (gm *GameManager) GetQueuePosition(playerID string) int {
gm.mu.RLock()
defer gm.mu.RUnlock()
for i, player := range gm.playerQueue {
if player.ID == playerID {
return i + 1
}
}
return -1
}