Files
ad_fun/chessstars/internal/game/handlers.go
T
2026-07-15 13:29:08 +03:00

426 lines
11 KiB
Go

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))
}