package game import ( "math" "net/http" "sort" "strconv" "chessstars/internal/auth" "chessstars/internal/engine" "chessstars/internal/httpx" ) const ( botRating = 1200 eloK = 32 ) func eloExpected(a, b int) float64 { return 1.0 / (1.0 + math.Pow(10, float64(b-a)/400.0)) } func eloNewRating(rating int, expected, actual float64) int { return rating + int(math.Round(eloK*(actual-expected))) } func (h *Handlers) scoreGame(entry *Entry) { if entry.Scored { return } status := entry.Engine.Status() if status == engine.StatusOngoing { return } entry.Scored = true var whiteActual, blackActual float64 switch status { case engine.StatusWhiteWins: whiteActual, blackActual = 1, 0 case engine.StatusBlackWins: whiteActual, blackActual = 0, 1 default: // stalemate / draw whiteActual, blackActual = 0.5, 0.5 } whiteUser, errW := h.authStore.GetUserByID(entry.WhiteID) var blackUser *auth.User var errB error if !entry.VsBot { blackUser, errB = h.authStore.GetUserByID(entry.BlackID) } blackRating := botRating if blackUser != nil { blackRating = blackUser.Rating } if errW == nil { newRating := eloNewRating(whiteUser.Rating, eloExpected(whiteUser.Rating, blackRating), whiteActual) _ = h.authStore.RecordResult(entry.WhiteID, newRating, outcomeFor(whiteActual)) } if !entry.VsBot && errB == nil { newRating := eloNewRating(blackUser.Rating, eloExpected(blackUser.Rating, whiteUser.Rating), blackActual) _ = h.authStore.RecordResult(entry.BlackID, newRating, outcomeFor(blackActual)) } } func outcomeFor(actual float64) string { switch actual { case 1: return auth.OutcomeWin case 0: return auth.OutcomeLoss default: return auth.OutcomeDraw } } type Handlers struct { manager *Manager authStore *auth.Store } func NewHandlers(m *Manager, authStore *auth.Store) *Handlers { return &Handlers{manager: m, authStore: authStore} } type wireMove struct { From uint8 `json:"from"` To uint8 `json:"to"` Piece uint8 `json:"piece"` Flags uint8 `json:"flags"` Captured uint8 `json:"captured"` Promo uint8 `json:"promo"` Reserved0 uint8 `json:"reserved0"` Reserved1 uint8 `json:"reserved1"` } func toEngineMove(w wireMove) engine.Move { return engine.Move{ From: w.From, To: w.To, Piece: w.Piece, Flags: w.Flags, Captured: w.Captured, Promo: w.Promo, Reserved0: w.Reserved0, Reserved1: w.Reserved1, } } func fromEngineMove(m engine.Move) wireMove { return wireMove{ From: m.From, To: m.To, Piece: m.Piece, Flags: m.Flags, Captured: m.Captured, Promo: m.Promo, Reserved0: m.Reserved0, Reserved1: m.Reserved1, } } func (h *Handlers) Create(w http.ResponseWriter, r *http.Request) { u, ok := auth.UserFromContext(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "not logged in") return } var req struct { Opponent string `json:"opponent"` Variant int `json:"variant"` StartFEN string `json:"start_fen"` RuleFlags uint32 `json:"rule_flags"` BotDepth int `json:"bot_depth"` } if err := httpx.ReadJSON(r, &req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid json") return } if req.BotDepth <= 0 { req.BotDepth = 3 } var blackID int64 var blackName string vsBot := req.Opponent == "bot" if !vsBot && req.Opponent != "" { opponent, err := h.authStore.GetUserByUsername(req.Opponent) if err != nil { httpx.Error(w, http.StatusNotFound, "no such user") return } if opponent.ID == u.ID { httpx.Error(w, http.StatusBadRequest, "can't challenge yourself") return } blackID = opponent.ID blackName = opponent.Username } entry, err := h.manager.Create( engine.Variant(req.Variant), req.StartFEN, engine.RuleFlags(req.RuleFlags), u.ID, u.Username, blackID, blackName, vsBot, req.BotDepth, ) if err != nil { httpx.Error(w, http.StatusInternalServerError, "could not create game") return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "id": entry.ID}) } func (h *Handlers) Join(w http.ResponseWriter, r *http.Request) { u, ok := auth.UserFromContext(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "not logged in") return } entry, err := h.manager.Join(r.PathValue("id"), u.ID, u.Username) if err != nil { switch err { case ErrNotFound: httpx.Error(w, http.StatusNotFound, "no such game") case ErrAlreadyJoined: httpx.Error(w, http.StatusConflict, "game already has an opponent") case ErrSelfJoin: httpx.Error(w, http.StatusBadRequest, "can't join your own game") default: httpx.Error(w, http.StatusInternalServerError, "could not join") } return } entry.Lock() defer entry.Unlock() httpx.WriteJSON(w, http.StatusOK, h.state(entry)) } type gameSummary struct { ID string `json:"id"` White string `json:"white"` Black string `json:"black"` VsBot bool `json:"vs_bot"` Status int `json:"status"` Turn int `json:"turn"` CreatedAt string `json:"created_at"` } func (h *Handlers) summary(entry *Entry) gameSummary { entry.Lock() defer entry.Unlock() black := entry.BlackName if entry.VsBot { black = "bot" } else if black == "" { black = "" } return gameSummary{ ID: entry.ID, White: entry.WhiteName, Black: black, VsBot: entry.VsBot, Status: entry.Engine.Status(), Turn: entry.Engine.Turn(), CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } } func (h *Handlers) ListOpen(w http.ResponseWriter, r *http.Request) { entries := h.manager.ListOpen() list := make([]gameSummary, 0, len(entries)) for _, e := range entries { list = append(list, h.summary(e)) } httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "games": list}) } func (h *Handlers) ListAll(w http.ResponseWriter, r *http.Request) { entries := h.manager.ListAll() sort.Slice(entries, func(i, j int) bool { entries[i].Lock() ti := entries[i].CreatedAt entries[i].Unlock() entries[j].Lock() tj := entries[j].CreatedAt entries[j].Unlock() return ti.After(tj) }) if len(entries) > 100 { entries = entries[:100] } list := make([]gameSummary, 0, len(entries)) for _, e := range entries { list = append(list, h.summary(e)) } httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "games": list}) } func (h *Handlers) ListMine(w http.ResponseWriter, r *http.Request) { u, ok := auth.UserFromContext(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "not logged in") return } entries := h.manager.ListForUser(u.ID) list := make([]gameSummary, 0, len(entries)) for _, e := range entries { list = append(list, h.summary(e)) } httpx.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "games": list}) } type gameState struct { ID string `json:"id"` Board [64]byte `json:"board"` Turn int `json:"turn"` Status int `json:"status"` InCheck bool `json:"in_check"` History int32 `json:"history_count"` White string `json:"white"` Black string `json:"black"` VsBot bool `json:"vs_bot"` WhiteID int64 `json:"white_id"` BlackID int64 `json:"black_id"` } func (h *Handlers) state(entry *Entry) gameState { return gameState{ ID: entry.ID, Board: entry.Engine.Board(), Turn: entry.Engine.Turn(), Status: entry.Engine.Status(), InCheck: entry.Engine.InCheck(), History: entry.Engine.HistoryCount(), White: entry.WhiteName, Black: entry.BlackName, VsBot: entry.VsBot, WhiteID: entry.WhiteID, BlackID: entry.BlackID, } } func (h *Handlers) Get(w http.ResponseWriter, r *http.Request) { entry, err := h.manager.Get(r.PathValue("id")) if err != nil { httpx.Error(w, http.StatusNotFound, "no such game") return } entry.Lock() defer entry.Unlock() httpx.WriteJSON(w, http.StatusOK, h.state(entry)) } func (h *Handlers) Move(w http.ResponseWriter, r *http.Request) { u, ok := auth.UserFromContext(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "not logged in") return } entry, err := h.manager.Get(r.PathValue("id")) if err != nil { httpx.Error(w, http.StatusNotFound, "no such game") return } var wm wireMove if err := httpx.ReadJSON(r, &wm); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid json") return } entry.Lock() defer entry.Unlock() if entry.Open() { httpx.Error(w, http.StatusBadRequest, "waiting for an opponent to join") return } turn := entry.Engine.Turn() turnUserID := entry.WhiteID if turn == engine.ColorBlack { turnUserID = entry.BlackID } if turnUserID != u.ID { httpx.Error(w, http.StatusForbidden, "not your turn") return } ok = entry.Engine.AddMove(toEngineMove(wm)) if !ok { httpx.Error(w, http.StatusBadRequest, "illegal move") return } if entry.VsBot && entry.Engine.Status() == engine.StatusOngoing { bot := entry.Engine.BotMove(entry.BotDepth) entry.Engine.AddMove(bot) } h.scoreGame(entry) httpx.WriteJSON(w, http.StatusOK, h.state(entry)) } func (h *Handlers) Legal(w http.ResponseWriter, r *http.Request) { entry, err := h.manager.Get(r.PathValue("id")) if err != nil { httpx.Error(w, http.StatusNotFound, "no such game") return } var wm wireMove if err := httpx.ReadJSON(r, &wm); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid json") return } entry.Lock() defer entry.Unlock() legal := entry.Engine.IsLegal(toEngineMove(wm)) httpx.WriteJSON(w, http.StatusOK, map[string]any{"legal": legal}) } func (h *Handlers) LegalFrom(w http.ResponseWriter, r *http.Request) { entry, err := h.manager.Get(r.PathValue("id")) if err != nil { httpx.Error(w, http.StatusNotFound, "no such game") return } from, err := strconv.Atoi(r.URL.Query().Get("from")) if err != nil || from < 0 || from > 63 { httpx.Error(w, http.StatusBadRequest, "bad from square") return } entry.Lock() defer entry.Unlock() targets := make([]int, 0, 8) for to := 0; to < 64; to++ { if to == from { continue } if entry.Engine.IsLegal(engine.Move{From: uint8(from), To: uint8(to), Promo: engine.PieceQueen}) { targets = append(targets, to) } } httpx.WriteJSON(w, http.StatusOK, map[string]any{"targets": targets}) } func (h *Handlers) Moves(w http.ResponseWriter, r *http.Request) { entry, err := h.manager.Get(r.PathValue("id")) if err != nil { httpx.Error(w, http.StatusNotFound, "no such game") return } moves, err := GenerateSAN(entry) if err != nil { httpx.Error(w, http.StatusInternalServerError, "could not generate notation") return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"moves": moves}) } func (h *Handlers) HistoryAt(w http.ResponseWriter, r *http.Request) { entry, err := h.manager.Get(r.PathValue("id")) if err != nil { httpx.Error(w, http.StatusNotFound, "no such game") return } idx, err := strconv.ParseInt(r.PathValue("index"), 10, 32) if err != nil { httpx.Error(w, http.StatusBadRequest, "bad index") return } entry.Lock() defer entry.Unlock() mv, ok := entry.Engine.GetMoveAt(int32(idx)) if !ok { httpx.Error(w, http.StatusNotFound, "no such move") return } httpx.WriteJSON(w, http.StatusOK, fromEngineMove(mv)) }