dkforest

A forum and chat platform (onion)
git clone https://git.dasho.dev/n0tr1v/dkforest.git
Log | Files | Refs | LICENSE

chess.go (36792B)


      1 package interceptors
      2 
      3 import (
      4 	"bytes"
      5 	"dkforest/pkg/config"
      6 	"dkforest/pkg/database"
      7 	dutils "dkforest/pkg/database/utils"
      8 	"dkforest/pkg/pubsub"
      9 	"dkforest/pkg/utils"
     10 	"encoding/base64"
     11 	"encoding/json"
     12 	"errors"
     13 	"fmt"
     14 	"github.com/fogleman/gg"
     15 	"github.com/google/uuid"
     16 	"github.com/labstack/echo"
     17 	"github.com/notnil/chess"
     18 	"github.com/notnil/chess/uci"
     19 	"github.com/sirupsen/logrus"
     20 	"html/template"
     21 	"image"
     22 	"image/color"
     23 	"image/png"
     24 	"math"
     25 	"sort"
     26 	"strconv"
     27 	"strings"
     28 	"sync"
     29 	"time"
     30 )
     31 
     32 type ChessMove struct {
     33 	IDStr1     string
     34 	IDStr2     string
     35 	EnPassant  string
     36 	CheckIDStr string
     37 	Move       chess.Move
     38 	MoveIdx    int
     39 	BestMove   string
     40 }
     41 
     42 type ChessAnalyzeProgress struct {
     43 	Step  int
     44 	Total int
     45 }
     46 
     47 var ChessAnalyzeProgressPubSub = pubsub.NewPubSub[ChessAnalyzeProgress]()
     48 var ChessPubSub = pubsub.NewPubSub[ChessMove]()
     49 
     50 type ChessPlayer struct {
     51 	ID              database.UserID
     52 	Username        database.Username
     53 	UserStyle       string
     54 	NotifyChessMove bool
     55 }
     56 
     57 type ChessGame struct {
     58 	DbChessGame    *database.ChessGame
     59 	Key            string
     60 	Game           *chess.Game
     61 	lastUpdated    time.Time
     62 	Player1        *ChessPlayer
     63 	Player2        *ChessPlayer
     64 	CreatedAt      time.Time
     65 	piecesCache    map[chess.Square]string
     66 	analyzing      bool
     67 	mtx            sync.RWMutex
     68 	analyzeProgrss ChessAnalyzeProgress
     69 }
     70 
     71 func (g *ChessGame) SetAnalyzeProgress(progress ChessAnalyzeProgress) {
     72 	g.mtx.Lock()
     73 	defer g.mtx.Unlock()
     74 	g.analyzeProgrss = progress
     75 }
     76 
     77 func (g *ChessGame) GetAnalyzeProgress() ChessAnalyzeProgress {
     78 	g.mtx.RLock()
     79 	defer g.mtx.RUnlock()
     80 	return g.analyzeProgrss
     81 }
     82 
     83 func newChessPlayer(player database.User) *ChessPlayer {
     84 	p := new(ChessPlayer)
     85 	p.ID = player.ID
     86 	p.Username = player.Username
     87 	p.UserStyle = player.GenerateChatStyle()
     88 	p.NotifyChessMove = player.NotifyChessMove
     89 	return p
     90 }
     91 
     92 func newChessGame(gameKey string, player1, player2 database.User, dbChessGame *database.ChessGame, pgn string) (*ChessGame, error) {
     93 	g := new(ChessGame)
     94 	g.DbChessGame = dbChessGame
     95 	g.CreatedAt = time.Now()
     96 	g.Key = gameKey
     97 	options := make([]func(*chess.Game), 0)
     98 	if pgn != "" {
     99 		if strings.HasSuffix(pgn, " 1-0") ||
    100 			strings.HasSuffix(pgn, " 0-1") ||
    101 			strings.HasSuffix(pgn, " 1/2-1/2") {
    102 			return nil, errors.New("pgn should have no outcome")
    103 		}
    104 		if !strings.HasSuffix(pgn, " *") {
    105 			pgn += " *"
    106 		}
    107 		p, err := chess.PGN(strings.NewReader(pgn))
    108 		if err != nil {
    109 			return nil, err
    110 		}
    111 		options = append(options, p)
    112 	}
    113 	g.Game = chess.NewGame(options...)
    114 	if g.Game.Outcome() != chess.NoOutcome {
    115 		return nil, errors.New("invalid pgn, outcome should be 'NoOutcome'")
    116 	}
    117 	if dbChessGame.PGN != "" {
    118 		pgnOpt, _ := chess.PGN(strings.NewReader(dbChessGame.PGN))
    119 		g.Game = chess.NewGame(pgnOpt)
    120 	}
    121 	g.lastUpdated = time.Now()
    122 	g.Player1 = newChessPlayer(player1)
    123 	g.Player2 = newChessPlayer(player2)
    124 	g.piecesCache = InitPiecesCache(g.Game.Moves())
    125 	return g, nil
    126 }
    127 
    128 type Chess struct {
    129 	sync.Mutex
    130 	db     *database.DkfDB
    131 	zeroID database.UserID
    132 	games  map[string]*ChessGame
    133 }
    134 
    135 func NewChess(db *database.DkfDB) *Chess {
    136 	zeroUser := dutils.GetZeroUser(db)
    137 	c := &Chess{db: db, zeroID: zeroUser.ID}
    138 	c.games = make(map[string]*ChessGame)
    139 
    140 	// Thread that cleanup inactive games
    141 	go func() {
    142 		for {
    143 			time.Sleep(15 * time.Minute)
    144 			c.Lock()
    145 			for k, g := range c.games {
    146 				if time.Since(g.lastUpdated) > 3*time.Hour {
    147 					delete(c.games, k)
    148 				}
    149 			}
    150 			c.Unlock()
    151 		}
    152 	}()
    153 
    154 	return c
    155 }
    156 
    157 var ChessInstance *Chess
    158 
    159 const (
    160 	sqSize    = 45
    161 	boardSize = 8 * sqSize
    162 
    163 	CheckColor    = "rgba(255, 0, 0, 0.4)"
    164 	LastMoveColor = "rgba(0, 255, 0, 0.2)"
    165 )
    166 
    167 func GetID(row, col int, isFlipped bool) (id int) {
    168 	if isFlipped {
    169 		id = row*8 + (7 - col)
    170 	} else {
    171 		id = (7-row)*8 + col
    172 	}
    173 	return id
    174 }
    175 
    176 var ChessCSS = `
    177 <style>
    178 #arrow {
    179 	transform-origin: top center !important;
    180 	display: none;
    181 	position: absolute;
    182 	top: 0;
    183 	left: 0;
    184 	width: 12.5%;
    185 	height: 12.5%;
    186 	z-index: 4;
    187 	pointer-events: none;
    188 }
    189 #arrow .triangle-up {
    190 	position: absolute;
    191 	width: 60%;
    192 	height: 45%;
    193 	left: 20%;
    194 	background: rgba(0, 0, 255, 0.6);
    195 	clip-path: polygon(0% 100%, 50% 0%, 100% 100%);
    196 }
    197 #arrow .rectangle {
    198 	position: absolute;
    199 	top: 45%;
    200 	left: 42.5%;
    201 	width: 15%;
    202 	height: 55%;
    203 	background-color: rgba(0, 0, 255, 0.6);
    204 	border-radius: 0 0 10px 10px
    205 }
    206 .newBoard {
    207 	position: relative;
    208 	aspect-ratio: 1 / 1;
    209 	width: 100%;
    210 	min-height: 360px;
    211 }
    212 .newBoard .img {
    213 	position: absolute;
    214 	width: 12.5%;
    215 	height: 12.5%;
    216 	background-size: 100%;
    217 }
    218 label {
    219 	position: absolute;
    220 	width: 12.5%;
    221 	height: 12.5%;
    222 }
    223 input[type=checkbox] {
    224     display:none;
    225 }
    226 input[type=checkbox] + label {
    227     display: inline-block;
    228     padding: 0 0 0 0;
    229 	margin: 0 0 0 0;
    230     background-size: 100%;
    231 	border: 3px solid transparent;
    232 	box-sizing: border-box;
    233 }
    234 input[type=checkbox]:checked + label {
    235     display: inline-block;
    236     background-size: 100%;
    237 	border: 3px solid red;
    238 }
    239 </style>`
    240 
    241 func (g *ChessGame) renderBoardHTML1(moveIdx int, position *chess.Position, isFlipped bool, imgB64 string, bestMove *chess.Move) string {
    242 	game := g.Game
    243 	moves := game.Moves()
    244 	var last *chess.Move
    245 	if len(moves) > 0 {
    246 		last = moves[len(moves)-1]
    247 		if moveIdx > 0 && moveIdx < len(moves) {
    248 			last = moves[moveIdx-1]
    249 		}
    250 	}
    251 
    252 	pieceInCheck := func(p chess.Piece) bool {
    253 		return last != nil && p.Color() == position.Turn() && p.Type() == chess.King && last.HasTag(chess.Check)
    254 	}
    255 	sqIsBestMove := func(sq chess.Square) bool {
    256 		return bestMove != nil && (bestMove.S1() == sq || bestMove.S2() == sq)
    257 	}
    258 	sqIsLastMove := func(sq chess.Square) bool {
    259 		return last != nil && (last.S1() == sq || last.S2() == sq)
    260 	}
    261 	getPieceFileName := func(p chess.Piece) string {
    262 		return "/public/img/chess/" + p.Color().String() + strings.ToUpper(p.Type().String()) + ".png"
    263 	}
    264 	getPid := func(sq chess.Square) string {
    265 		if sq.Rank() == chess.Rank1 || sq.Rank() == chess.Rank2 || sq.Rank() == chess.Rank7 || sq.Rank() == chess.Rank8 {
    266 			return "piece_" + sq.String()
    267 		}
    268 		return ""
    269 	}
    270 	pieceFromSq := func(sq chess.Square) chess.Piece {
    271 		game := chess.NewGame()
    272 		boardMap := game.Position().Board().SquareMap()
    273 		return boardMap[sq]
    274 	}
    275 	pieceFromSq1 := func(sq chess.Square) chess.Piece {
    276 		boardMap := game.Position().Board().SquareMap()
    277 		return boardMap[sq]
    278 	}
    279 
    280 	htmlTmpl := ChessCSS + `
    281 <table class="newBoard" style="	background-repeat: no-repeat; background-size: cover; background-image: url(data:image/png;base64,{{ .ImgB64 }}); overflow: hidden;">
    282 	{{ range $row := .Rows }}
    283 		<tr>
    284 			{{ range $col := $.Cols }}
    285 				{{ $id := GetID $row $col }}
    286 				{{ $sq := Square $id }}
    287 				{{ $pidStr := GetPid $sq }}
    288 				<td class="square square_{{ $id }}" style="background-color: {{ if IsBestMove $sq }}rgba(0, 0, 255, 0.2){{ else if IsLastMove $sq }}{{ $.LastMoveColor | css }}{{ else }}transparent{{ end }};">
    289 					{{ if and (eq $col 0) (eq $row 0) }}
    290 						<div id="arrow"><div class="triangle-up"></div><div class="rectangle"></div></div>
    291 					{{ end }}
    292 					{{ if $pidStr }}
    293 						{{ $p := PieceFromSq $sq }}
    294 						<div id="{{ $pidStr }}" class="img" style="display: none; background-image: url({{ GetPieceFileName $p }});">
    295 							{{ if or (eq $pidStr "piece_e8") (eq $pidStr "piece_e1") }}
    296 								{{ if $.WhiteWon }}
    297 									{{ if eq $pidStr "piece_e8" }}
    298 										<div id="{{ $pidStr }}_loser" style="display: block;" class="outcome loser"></div>
    299 									{{ else }}
    300 										<div id="{{ $pidStr }}_winner" style="display: block;" class="outcome winner"></div>
    301 									{{ end }}
    302 								{{ else if $.BlackWon }}
    303 									{{ if eq $pidStr "piece_e8" }}
    304 										<div id="{{ $pidStr }}_winner" style="display: block;" class="outcome winner"></div>
    305 									{{ else }}
    306 										<div id="{{ $pidStr }}_loser" style="display: block;" class="outcome loser"></div>
    307 									{{ end }}
    308 								{{ else if $.Draw }}
    309 									<div id="{{ $pidStr }}_draw" style="display: block;" class="outcome draw"></div>
    310 								{{ else }}
    311 									<div id="{{ $pidStr }}_draw" style="display: none;" class="outcome draw"></div>
    312 									<div id="{{ $pidStr }}_winner" style="display: none;" class="outcome winner"></div>
    313 									<div id="{{ $pidStr }}_loser" style="display: none;" class="outcome loser"></div>
    314 								{{ end }}
    315 							{{ end }}
    316 						</div>
    317 					{{ end }}
    318 				</td>
    319 			{{ end }}
    320 		</tr>
    321 	{{ end }}
    322 </table>
    323 <style>
    324 {{- range $row := .Rows -}}
    325 	{{ range $col := $.Cols -}}
    326 		{{- $id := GetID $row $col -}}
    327 		{{- $sq := Square $id -}}
    328 		{{- $p := PieceFromSq1 $sq -}}
    329 		{{- $pidStr := GetPid1 $sq -}}
    330 		{{- if $pidStr -}}
    331 			#{{ $pidStr }} {
    332 				display: block !important;
    333 				background-image: url("{{ GetPieceFileName $p }}") !important;
    334 				left: calc({{ $col }}*12.5%); top: calc({{ $row }}*12.5%);
    335 				background-color: {{ if PieceInCheck $p }}{{ $.CheckColor | css }}{{ else }}transparent{{ end }};
    336 			}
    337 		{{- end -}}
    338 	{{- end -}}
    339 {{- end -}}
    340 </style>
    341 `
    342 
    343 	data := map[string]any{
    344 		"ImgB64":        imgB64,
    345 		"Rows":          []int{0, 1, 2, 3, 4, 5, 6, 7},
    346 		"Cols":          []int{0, 1, 2, 3, 4, 5, 6, 7},
    347 		"LastMoveColor": LastMoveColor,
    348 		"CheckColor":    CheckColor,
    349 		"WhiteWon":      game.Outcome() == chess.WhiteWon,
    350 		"BlackWon":      game.Outcome() == chess.BlackWon,
    351 		"Draw":          game.Outcome() == chess.Draw,
    352 	}
    353 
    354 	fns := template.FuncMap{
    355 		"GetID":            func(row, col int) int { return GetID(row, col, isFlipped) },
    356 		"IsBestMove":       sqIsBestMove,
    357 		"IsLastMove":       sqIsLastMove,
    358 		"PieceInCheck":     pieceInCheck,
    359 		"GetPieceFileName": getPieceFileName,
    360 		"GetPid":           getPid,
    361 		"GetPid1":          func(sq chess.Square) string { return g.piecesCache[sq] },
    362 		"Square":           func(id int) chess.Square { return chess.Square(id) },
    363 		"PieceFromSq":      pieceFromSq,
    364 		"PieceFromSq1":     pieceFromSq1,
    365 		"css":              func(s string) template.CSS { return template.CSS(s) },
    366 		"cssUrl":           func(s string) template.URL { return template.URL(s) },
    367 	}
    368 
    369 	var buf bytes.Buffer
    370 	if err := utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf, data); err != nil {
    371 		logrus.Error(err)
    372 	}
    373 	return buf.String()
    374 }
    375 
    376 func renderBoardPng(isFlipped bool) image.Image {
    377 	ctx := gg.NewContext(boardSize, boardSize)
    378 	for i := 0; i < 64; i++ {
    379 		sq := chess.Square(i)
    380 		renderSquare(ctx, sq, isFlipped)
    381 	}
    382 	return ctx.Image()
    383 }
    384 
    385 func XyForSquare(isFlipped bool, sq chess.Square) (x, y int) {
    386 	fileIndex := int(sq.File())
    387 	rankIndex := 7 - int(sq.Rank())
    388 	x = fileIndex * sqSize
    389 	y = rankIndex * sqSize
    390 	if isFlipped {
    391 		x = boardSize - x - sqSize
    392 		y = boardSize - y - sqSize
    393 	}
    394 	return
    395 }
    396 
    397 func colorForSquare(sq chess.Square) color.RGBA {
    398 	sqSum := int(sq.File()) + int(sq.Rank())
    399 	if sqSum%2 == 0 {
    400 		return color.RGBA{R: 165, G: 117, B: 81, A: 255}
    401 	}
    402 	return color.RGBA{R: 235, G: 209, B: 166, A: 255}
    403 }
    404 
    405 func renderSquare(ctx *gg.Context, sq chess.Square, isFlipped bool) {
    406 	x, y := XyForSquare(isFlipped, sq)
    407 	// draw square
    408 	ctx.Push()
    409 	ctx.SetColor(colorForSquare(sq))
    410 	ctx.DrawRectangle(float64(x), float64(y), sqSize, sqSize)
    411 	ctx.Fill()
    412 	ctx.Pop()
    413 
    414 	// Draw file/rank
    415 	ctx.Push()
    416 	ctx.SetColor(color.RGBA{R: 0, G: 0, B: 0, A: 180})
    417 	if (!isFlipped && sq.Rank() == chess.Rank1) || (isFlipped && sq.Rank() == chess.Rank8) {
    418 		ctx.DrawString(sq.File().String(), float64(x+sqSize-7), float64(y+sqSize-1))
    419 	}
    420 	if (!isFlipped && sq.File() == chess.FileA) || (isFlipped && sq.File() == chess.FileH) {
    421 		ctx.DrawString(sq.Rank().String(), float64(x+1), float64(y+11))
    422 	}
    423 	ctx.Pop()
    424 }
    425 
    426 func (g *ChessGame) renderBoardHTML(moveIdx int, isFlipped bool, imgB64 string, bestMove *chess.Move) string {
    427 	position := g.Game.Position()
    428 	if moveIdx != 0 && moveIdx < len(g.Game.Positions()) {
    429 		position = g.Game.Positions()[moveIdx]
    430 	}
    431 	out := g.renderBoardHTML1(moveIdx, position, isFlipped, imgB64, bestMove)
    432 	return out
    433 }
    434 
    435 func (g *ChessGame) renderBoardB64(isFlipped bool) string {
    436 	var buf bytes.Buffer
    437 	img := renderBoardPng(isFlipped)
    438 	_ = png.Encode(&buf, img)
    439 	imgB64 := base64.StdEncoding.EncodeToString(buf.Bytes())
    440 	return imgB64
    441 }
    442 
    443 func (g *ChessGame) DrawPlayerCard(moveIdx int, key string, isBlack, soundsEnabled, canUseChessAnalyze bool) string {
    444 	return g.drawPlayerCard(moveIdx, key, isBlack, false, soundsEnabled, canUseChessAnalyze)
    445 }
    446 
    447 func (g *ChessGame) drawPlayerCard(moveIdx int, key string, isBlack, isSpectator, soundsEnabled, canUseChessAnalyze bool) string {
    448 	htmlTmpl := `
    449 <style>
    450 #p1Status {
    451 }
    452 #p2Status {
    453 }
    454 #p1Status, #p2Status {
    455 	width: 16px; height: 16px; border-radius: 8px;
    456 	background-color: darkred;
    457 	display: inline-block;
    458 }
    459 #white-advantage:before { content: "{{ .WhiteAdvantage }}"; }
    460 #white-advantage .score:after { content: "{{ .WhiteScore }}"; }
    461 #black-advantage:before { content: "{{ .BlackAdvantage }}"; }
    462 #black-advantage .score:after { content: "{{ .BlackScore }}"; }
    463 #outcome:after { content: "{{ .Outcome }}"; }
    464 .score { font-size: 11px; }
    465 .outcome {
    466 	color: white;
    467 	border-radius: 10px;
    468 	width: 20px;
    469 	height: 17px;
    470 	padding-top: 3px;
    471 	font-family: Helvetica;
    472 	margin: 1px 0 0 1px;
    473 	text-align: center;
    474 }
    475 .winner { background-color: green; }
    476 .loser  { background-color: red;   }
    477 .draw   { background-color: #666;  }
    478 .winner::after { content: "W" }
    479 .loser::after  { content: "L" }
    480 .draw::after   { content: "½" }
    481 
    482 @keyframes winner_anim {
    483 	0% { border-radius: 0px; width: 100%; height: 100%; padding: 0; opacity: 0.0; margin: 0; }
    484 	20% { border-radius: 0px; width: 100%; height: 100%; padding: 0; opacity: 0.75; margin: 0; }
    485 	75% { border-radius: 0px; width: 100%; height: 100%; padding: 0; opacity: 0.75; margin: 0; }
    486 	100% { border-radius: 10px; width: 20px; height: 17px; padding: 3px 0 0 0; opacity: 1; margin: 1px 0 0 1px; }
    487 }
    488 </style>
    489 <table style="width: 100%; height: 100%;">
    490 	<tr>
    491 		<td align="center">
    492 			<table style="aspect-ratio: 1/1; height: 70%; max-width: 90%;">
    493 				<tr>
    494 					<td style="padding: 10px 0;" colspan="2">
    495 						<table>
    496 							<tr>
    497 								<td style="padding-right: 10px;"><div id="p1Status"></div></td>
    498 								<td>
    499 									<span style="color: #eee; vertical-align: bottom;">
    500 										<span {{ .White.UserStyle | attr }}>@{{ .White.Username }}</span> (white) VS
    501 										<span {{ .Black.UserStyle | attr }}>@{{ .Black.Username }}</span> (black)
    502 									</span>
    503 								</td>
    504 								<td style="padding-left: 10px;"><div id="p2Status"></div></td>
    505 							</tr>
    506 						</table>
    507 					</td>
    508 				</tr>
    509 				<tr>
    510 					<td>
    511 						<span style="color: #eee; display: inline-block;">
    512 							(<span id="white-advantage" style="color: #888;" title="white advantage"><span class="score"></span></span> |
    513 							<span id="black-advantage" style="color: #888;" title="black advantage"><span class="score"></span></span>)
    514 						</span>
    515 					</td>
    516 					<td align="right" style="vertical-align: middle;">
    517 						<a href="/settings/chat" rel="noopener noreferrer" target="_blank">
    518 							{{ if .SoundsEnabled }}
    519 								<img src="/public/img/sounds-enabled.png" style="height: 20px;" alt="" title="Sounds enabled" />
    520  							{{ else }}
    521 								<img src="/public/img/no-sound.png" style="height: 20px;" alt="" title="Sounds disabled" />
    522  							{{ end }}
    523 						</a>
    524 					</td>
    525 				</tr>
    526 				<tr>
    527 					<td colspan="2">
    528 						{{ if .GameOver }}
    529 							<div style="position: relative;">
    530 								<iframe src="/chess/{{ .Key }}/form" style="position: absolute; top: 0; left: 0; border: 0px solid red; z-index: 999; width: 100%; height: 100%;"></iframe>
    531 								<div style="aspect-ratio: 1/1;">
    532 									{{ .Table }}
    533 								</div>
    534 							</div>
    535 						{{ else if or .IsSpectator }}
    536 							{{ .Table }}
    537 						{{ else }}
    538 							<div style="position: relative;">
    539 								<iframe src="/chess/{{ .Key }}/form" style="position: absolute; top: 0; left: 0; border: 0px solid red; z-index: 999; width: 100%; height: 100%;"></iframe>
    540 								<div style="aspect-ratio: 1/1;">
    541 									{{ .Table }}
    542 									<div style="height: 33px;"></div>
    543 								</div>
    544 							</div>
    545 						{{ end }}
    546 					</td>
    547 				</tr>
    548 				{{ if .IsSpectator }}
    549 					<tr><td style="padding: 10px 0;" colspan="2"><a href="?{{ if not .IsFlipped }}r=1{{ end }}" style="color: #eee;">Flip board</a></td></tr>
    550 				{{ end }}
    551 				<tr style="height: 100%;">
    552 					<td colspan="2">
    553 						<div style="color: #eee; display: inline-block;">Outcome: <span id="outcome"></span></div>
    554 						{{ if and .GameOver .CanUseChessAnalyze }}
    555 							<a style="color: #eee; margin-left: 20px;" href="/chess/{{ .Key }}/analyze">Analyze</a>
    556 						{{ end }}
    557 					</td>
    558 				</tr>
    559 				{{ if .GameOver }}<tr><td colspan="2"><div><textarea readonly>{{ .PGN }}</textarea></div></td></tr>{{ end }}
    560 				{{ if .Stats }}
    561 					<tr>
    562 						<td colspan="2">
    563 							<iframe name="iframeStats" src="/chess/{{ .Key }}/stats" style="width: 100%; height: 240px; margin: 10px 0; border: 3px solid black;"></iframe>
    564 							{{ if .IsAnalyzed }}
    565 								<div style="color: #eee;">White accuracy: <span id="white-accuracy">{{ .WhiteAccuracy | pct }}</span></div>
    566 								<div style="color: #eee;">Black accuracy: <span id="black-accuracy">{{ .BlackAccuracy | pct }}</span></div>
    567 							{{ end }}
    568 						</td>
    569 					</tr>
    570 				{{ end }}
    571 			</table>
    572 		</td>
    573 	</tr>
    574 </table>
    575 `
    576 
    577 	player1 := g.Player1
    578 	player2 := g.Player2
    579 	game := g.Game
    580 	enemy := utils.Ternary(isBlack, player1, player2)
    581 	imgB64 := g.renderBoardB64(isBlack)
    582 	whiteAdvantage, whiteScore, blackAdvantage, blackScore := CalcAdvantage(game.Position())
    583 
    584 	const graphWidth = 800
    585 	var columnWidth = 1
    586 	var stats *AnalyzeResult
    587 	_ = json.Unmarshal(g.DbChessGame.Stats, &stats)
    588 	var bestMove *chess.Move
    589 	if stats != nil {
    590 		if len(stats.Scores) > 0 {
    591 			if moveIdx > 0 && moveIdx < len(g.Game.Positions()) {
    592 				position := g.Game.Positions()[moveIdx]
    593 				bestMoveStr := stats.Scores[moveIdx-1].BestMove
    594 				var err error
    595 				bestMove, err = chess.UCINotation{}.Decode(position, bestMoveStr)
    596 				if err != nil {
    597 					logrus.Error(err)
    598 				}
    599 			}
    600 			columnWidth = utils.MaxInt(graphWidth/len(stats.Scores), 1)
    601 		}
    602 	}
    603 
    604 	data := map[string]any{
    605 		"SoundsEnabled":      soundsEnabled,
    606 		"Key":                key,
    607 		"IsFlipped":          isBlack,
    608 		"IsSpectator":        isSpectator,
    609 		"White":              player1,
    610 		"Black":              player2,
    611 		"Username":           enemy.Username,
    612 		"Table":              template.HTML(g.renderBoardHTML(moveIdx, isBlack, imgB64, bestMove)),
    613 		"Outcome":            game.Outcome().String(),
    614 		"GameOver":           game.Outcome() != chess.NoOutcome,
    615 		"PGN":                game.String(),
    616 		"WhiteAdvantage":     whiteAdvantage,
    617 		"WhiteScore":         whiteScore,
    618 		"BlackAdvantage":     blackAdvantage,
    619 		"BlackScore":         blackScore,
    620 		"IsAnalyzed":         g.DbChessGame.AccuracyWhite != 0 && g.DbChessGame.AccuracyBlack != 0,
    621 		"WhiteAccuracy":      g.DbChessGame.AccuracyWhite,
    622 		"BlackAccuracy":      g.DbChessGame.AccuracyBlack,
    623 		"Stats":              stats,
    624 		"ColumnWidth":        columnWidth,
    625 		"CanUseChessAnalyze": canUseChessAnalyze,
    626 		"MoveIdx":            moveIdx,
    627 	}
    628 
    629 	fns := template.FuncMap{
    630 		"attr": func(s string) template.HTMLAttr {
    631 			return template.HTMLAttr(s)
    632 		},
    633 		"pct": func(v float64) string {
    634 			return fmt.Sprintf("%.1f%%", v)
    635 		},
    636 	}
    637 
    638 	var buf1 bytes.Buffer
    639 	if err := utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf1, data); err != nil {
    640 		logrus.Error(err)
    641 	}
    642 	return buf1.String()
    643 }
    644 
    645 func (g *ChessGame) DrawSpectatorCard(moveIdx int, key string, isFlipped, soundsEnabled, canUseChessAnalyze bool) string {
    646 	return g.drawPlayerCard(moveIdx, key, isFlipped, true, soundsEnabled, canUseChessAnalyze)
    647 }
    648 
    649 func (g *ChessGame) SetAnalyzing() bool {
    650 	g.mtx.Lock()
    651 	defer g.mtx.Unlock()
    652 	if g.analyzing {
    653 		return false
    654 	}
    655 	g.analyzing = true
    656 	return true
    657 }
    658 
    659 func (g *ChessGame) UnsetAnalyzing() {
    660 	g.mtx.Lock()
    661 	defer g.mtx.Unlock()
    662 	g.analyzing = false
    663 	g.analyzeProgrss = ChessAnalyzeProgress{}
    664 }
    665 
    666 func (g *ChessGame) IsAnalyzing() bool {
    667 	g.mtx.RLock()
    668 	defer g.mtx.RUnlock()
    669 	return g.analyzing
    670 }
    671 
    672 func (b *Chess) GetGame(key string) (*ChessGame, error) {
    673 	b.Lock()
    674 	defer b.Unlock()
    675 	dbChessGame, err := b.db.GetChessGame(key)
    676 	if err != nil {
    677 		return nil, err
    678 	}
    679 	if g, ok := b.games[key]; ok {
    680 		return g, nil
    681 	}
    682 	player1, _ := b.db.GetUserByID(dbChessGame.WhiteUserID)
    683 	player2, _ := b.db.GetUserByID(dbChessGame.BlackUserID)
    684 	g, err := newChessGame(key, player1, player2, dbChessGame, "")
    685 	if err != nil {
    686 		return nil, err
    687 	}
    688 	b.games[key] = g
    689 	return g, nil
    690 }
    691 
    692 func (b *Chess) GetGames() (out []ChessGame) {
    693 	b.Lock()
    694 	defer b.Unlock()
    695 	for _, v := range b.games {
    696 		out = append(out, *v)
    697 	}
    698 	sort.Slice(out, func(i, j int) bool {
    699 		return out[i].CreatedAt.After(out[j].CreatedAt)
    700 	})
    701 	return
    702 }
    703 
    704 func (b *Chess) NewGame1(roomKey string, roomID database.RoomID, player1, player2 database.User, color string) (*ChessGame, error) {
    705 	return b.NewGameWithPgn(roomKey, roomID, player1, player2, color, "")
    706 }
    707 
    708 func (b *Chess) NewGameWithPgn(roomKey string, roomID database.RoomID, player1, player2 database.User, color, pgn string) (*ChessGame, error) {
    709 	if player1.ID == player2.ID {
    710 		return nil, errors.New("can't play yourself")
    711 	}
    712 	if color == "r" {
    713 		color = utils.RandChoice([]string{"w", "b"})
    714 	}
    715 	if color == "b" {
    716 		player1, player2 = player2, player1
    717 	}
    718 
    719 	key := uuid.New().String()
    720 	g, err := b.NewGame(key, player1, player2, pgn)
    721 	if err != nil {
    722 		return nil, err
    723 	}
    724 
    725 	zeroUser := dutils.GetZeroUser(b.db)
    726 	dutils.SendNewChessGameMessages(b.db, key, roomKey, roomID, zeroUser, player1, player2)
    727 	return g, nil
    728 }
    729 
    730 func (b *Chess) NewGame(gameKey string, user1, user2 database.User, pgn string) (*ChessGame, error) {
    731 	dbChessGame, err := b.db.CreateChessGame(gameKey, user1.ID, user2.ID)
    732 	if err != nil {
    733 		return nil, err
    734 	}
    735 	g, err := newChessGame(gameKey, user1, user2, dbChessGame, pgn)
    736 	if err != nil {
    737 		return nil, err
    738 	}
    739 	b.Lock()
    740 	b.games[gameKey] = g
    741 	b.Unlock()
    742 	return g, nil
    743 }
    744 
    745 func (b *Chess) SendMove(gameKey string, userID database.UserID, g *ChessGame, c echo.Context) error {
    746 	player1 := g.Player1
    747 	player2 := g.Player2
    748 	game := g.Game
    749 
    750 	if (game.Position().Turn() == chess.White && userID != player1.ID) ||
    751 		(game.Position().Turn() == chess.Black && userID != player2.ID) {
    752 		return errors.New("not your turn")
    753 	}
    754 
    755 	moveIdx, _ := strconv.Atoi(c.Request().PostFormValue("move_idx"))
    756 	if moveIdx < len(g.Game.Moves())-1 {
    757 		return errors.New("double submission")
    758 	}
    759 
    760 	piecesCache := g.piecesCache
    761 
    762 	currentPlayer := player1
    763 	opponentPlayer := player2
    764 	if game.Position().Turn() == chess.Black {
    765 		currentPlayer = player2
    766 		opponentPlayer = player1
    767 	}
    768 
    769 	selectedSquares := make([]chess.Square, 0)
    770 	for i := 0; i < 64; i++ {
    771 		if utils.DoParseBool(c.Request().PostFormValue("sq_" + strconv.Itoa(i))) {
    772 			selectedSquares = append(selectedSquares, chess.Square(i))
    773 		}
    774 	}
    775 
    776 	if len(selectedSquares) != 2 {
    777 		return errors.New("must select 2 squares")
    778 	}
    779 
    780 	promo := chess.Queen
    781 	switch c.Request().PostFormValue("promotion") {
    782 	case "queen":
    783 		promo = chess.Queen
    784 	case "rook":
    785 		promo = chess.Rook
    786 	case "knight":
    787 		promo = chess.Knight
    788 	case "bishop":
    789 		promo = chess.Bishop
    790 	}
    791 
    792 	fst := selectedSquares[0]
    793 	scd := selectedSquares[1]
    794 
    795 	compareSquares := func(sq1, sq2, wanted1, wanted2 chess.Square) bool {
    796 		return (sq1 == wanted1 && sq2 == wanted2) ||
    797 			(sq1 == wanted2 && sq2 == wanted1)
    798 	}
    799 
    800 	// WKSq -> White King Square | WKSC -> White King Side Castle
    801 	isWKSq := func(m *chess.Move) bool { return m.S1() == chess.E1 || m.S2() == chess.E1 }
    802 	isBKSq := func(m *chess.Move) bool { return m.S1() == chess.E8 || m.S2() == chess.E8 }
    803 	isWKSC := func(m *chess.Move) bool { return isWKSq(m) && m.HasTag(chess.KingSideCastle) }
    804 	isBKSC := func(m *chess.Move) bool { return isBKSq(m) && m.HasTag(chess.KingSideCastle) }
    805 	isWQSC := func(m *chess.Move) bool { return isWKSq(m) && m.HasTag(chess.QueenSideCastle) }
    806 	isBQSC := func(m *chess.Move) bool { return isBKSq(m) && m.HasTag(chess.QueenSideCastle) }
    807 
    808 	var moveStr string
    809 	validMoves := game.Position().ValidMoves()
    810 	var found bool
    811 	var mov chess.Move
    812 	for _, move := range validMoves {
    813 		if (compareSquares(fst, scd, move.S1(), move.S2()) && (move.Promo() == chess.NoPieceType || move.Promo() == promo)) ||
    814 			(isWKSC(move) && compareSquares(fst, scd, chess.E1, chess.H1)) ||
    815 			(isBKSC(move) && compareSquares(fst, scd, chess.E8, chess.H8)) ||
    816 			(isWQSC(move) && compareSquares(fst, scd, chess.E1, chess.A1)) ||
    817 			(isBQSC(move) && compareSquares(fst, scd, chess.E8, chess.A8)) {
    818 			moveStr = chess.AlgebraicNotation{}.Encode(game.Position(), move)
    819 			found = true
    820 			mov = *move
    821 			break
    822 		}
    823 	}
    824 
    825 	if !found {
    826 		return fmt.Errorf("invalid move %s %s", fst, scd)
    827 	}
    828 
    829 	//fmt.Println(moveStr)
    830 
    831 	_ = game.MoveStr(moveStr)
    832 	g.lastUpdated = time.Now()
    833 	g.DbChessGame.PGN = game.String()
    834 	g.DbChessGame.Outcome = game.Outcome().String()
    835 	g.DbChessGame.DoSave(b.db)
    836 	idStr1 := piecesCache[mov.S1()]
    837 	idStr2 := piecesCache[mov.S2()]
    838 	idStr3 := ""
    839 
    840 	if mov.S1().Rank() == chess.Rank5 && mov.S2().Rank() == chess.Rank6 && mov.HasTag(chess.EnPassant) {
    841 		idStr3 = piecesCache[chess.NewSquare(mov.S2().File(), chess.Rank5)]
    842 	} else if mov.S1().Rank() == chess.Rank4 && mov.S2().Rank() == chess.Rank3 && mov.HasTag(chess.EnPassant) {
    843 		idStr3 = piecesCache[chess.NewSquare(mov.S2().File(), chess.Rank4)]
    844 	}
    845 
    846 	updatePiecesCache(piecesCache, mov)
    847 
    848 	var checkIDStr string
    849 	if mov.HasTag(chess.Check) {
    850 		checkIDStr = utils.Ternary(game.Position().Turn() == chess.White, WhiteKingID, BlackKingID)
    851 	}
    852 
    853 	chessMov := ChessMove{
    854 		IDStr1:     idStr1,
    855 		IDStr2:     idStr2,
    856 		EnPassant:  idStr3,
    857 		CheckIDStr: checkIDStr,
    858 		Move:       mov,
    859 	}
    860 	ChessPubSub.Pub(gameKey, chessMov)
    861 
    862 	// Notify (pm) the opponent that you made a move
    863 	if opponentPlayer.NotifyChessMove {
    864 		msg := fmt.Sprintf("@%s played %s", currentPlayer.Username, moveStr)
    865 		msg, _ = dutils.ColorifyTaggedUsers(msg, b.db.GetUsersByUsername)
    866 		chatMsg, _ := b.db.CreateMsg(msg, msg, "", config.GeneralRoomID, b.zeroID, &opponentPlayer.ID, false)
    867 		go func() {
    868 			time.Sleep(30 * time.Second)
    869 			_ = chatMsg.Delete(b.db)
    870 		}()
    871 	}
    872 
    873 	return nil
    874 }
    875 
    876 func (g *ChessGame) IsBlack(userID database.UserID) bool {
    877 	return userID == g.Player2.ID
    878 }
    879 
    880 func (g *ChessGame) IsPlayer(userID database.UserID) bool {
    881 	return g.Player1.ID == userID || g.Player2.ID == userID
    882 }
    883 
    884 func (g *ChessGame) MakeMoves(movesStr string, db *database.DkfDB) {
    885 	moves := strings.Split(movesStr, " ")
    886 	for _, move := range moves {
    887 		g.MoveStr(move)
    888 	}
    889 	g.DbChessGame.PGN = g.Game.String()
    890 	g.DbChessGame.Outcome = g.Game.Outcome().String()
    891 	g.DbChessGame.DoSave(db)
    892 }
    893 
    894 func (g *ChessGame) MoveStr(m string) {
    895 	game := g.Game
    896 	piecesCache := g.piecesCache
    897 	validMoves := game.Position().ValidMoves()
    898 	var mov chess.Move
    899 	for _, move := range validMoves {
    900 		moveStr := chess.AlgebraicNotation{}.Encode(game.Position(), move)
    901 		if moveStr == m {
    902 			mov = *move
    903 			break
    904 		}
    905 	}
    906 
    907 	updatePiecesCache(piecesCache, mov)
    908 
    909 	_ = game.MoveStr(m)
    910 }
    911 
    912 const (
    913 	WhiteKingID          = "piece_e1"
    914 	BlackKingID          = "piece_e8"
    915 	WhiteKingSideRookID  = "piece_h1"
    916 	BlackKingSideRookID  = "piece_h8"
    917 	WhiteQueenSideRookID = "piece_a1"
    918 	BlackQueenSideRookID = "piece_a8"
    919 )
    920 
    921 func InitPiecesCache(moves []*chess.Move) map[chess.Square]string {
    922 	piecesCache := make(map[chess.Square]string)
    923 	game := chess.NewGame()
    924 	pos := game.Position()
    925 	for i := 0; i < 64; i++ {
    926 		sq := chess.Square(i)
    927 		if pos.Board().Piece(sq) != chess.NoPiece {
    928 			piecesCache[sq] = "piece_" + sq.String()
    929 		}
    930 	}
    931 	for _, m := range moves {
    932 		updatePiecesCache(piecesCache, *m)
    933 	}
    934 	return piecesCache
    935 }
    936 
    937 func updatePiecesCache(piecesCache map[chess.Square]string, mov chess.Move) {
    938 	idStr1 := piecesCache[mov.S1()]
    939 	delete(piecesCache, mov.S1())
    940 	delete(piecesCache, mov.S2())
    941 	piecesCache[mov.S2()] = idStr1
    942 	if mov.S1().Rank() == chess.Rank6 && mov.S2().Rank() == chess.Rank7 && mov.HasTag(chess.EnPassant) {
    943 		delete(piecesCache, chess.NewSquare(mov.S2().File(), chess.Rank6))
    944 	} else if mov.S1().Rank() == chess.Rank5 && mov.S2().Rank() == chess.Rank4 && mov.HasTag(chess.EnPassant) {
    945 		delete(piecesCache, chess.NewSquare(mov.S2().File(), chess.Rank5))
    946 	}
    947 	if mov.S1() == chess.E1 && mov.HasTag(chess.KingSideCastle) {
    948 		delete(piecesCache, chess.H1)
    949 		piecesCache[chess.F1] = WhiteKingSideRookID
    950 	} else if mov.S1() == chess.E8 && mov.HasTag(chess.KingSideCastle) {
    951 		delete(piecesCache, chess.H8)
    952 		piecesCache[chess.F8] = BlackKingSideRookID
    953 	} else if mov.S1() == chess.E1 && mov.HasTag(chess.QueenSideCastle) {
    954 		delete(piecesCache, chess.A1)
    955 		piecesCache[chess.D1] = WhiteQueenSideRookID
    956 	} else if mov.S1() == chess.E8 && mov.HasTag(chess.QueenSideCastle) {
    957 		delete(piecesCache, chess.A8)
    958 		piecesCache[chess.D8] = BlackQueenSideRookID
    959 	}
    960 }
    961 
    962 // Creates a map of pieces on the board and their count
    963 func pieceMap(board *chess.Board) map[chess.Piece]int {
    964 	m := board.SquareMap()
    965 	out := make(map[chess.Piece]int)
    966 	for _, piece := range m {
    967 		out[piece]++
    968 	}
    969 	return out
    970 }
    971 
    972 /**
    973 white chess king	♔	U+2654	&#9812;	&#x2654;
    974 white chess queen	♕	U+2655	&#9813;	&#x2655;
    975 white chess rook	♖	U+2656	&#9814;	&#x2656;
    976 white chess bishop	♗	U+2657	&#9815;	&#x2657;
    977 white chess knight	♘	U+2658	&#9816;	&#x2658;
    978 white chess pawn	♙	U+2659	&#9817;	&#x2659;
    979 black chess king	♚	U+265A	&#9818;	&#x265A;
    980 black chess queen	♛	U+265B	&#9819;	&#x265B;
    981 black chess rook	♜	U+265C	&#9820;	&#x265C;
    982 black chess bishop	♝	U+265D	&#9821;	&#x265D;
    983 black chess knight	♞	U+265E	&#9822;	&#x265E;
    984 black chess pawn	♟︎	U+265F	&#9823;	&#x265F;
    985 */
    986 
    987 // CalcAdvantage ...
    988 func CalcAdvantage(position *chess.Position) (string, string, string, string) {
    989 	m := pieceMap(position.Board())
    990 	var whiteAdvantage, blackAdvantage string
    991 	var whiteScore, blackScore int
    992 	diff := m[chess.WhiteQueen] - m[chess.BlackQueen]
    993 	whiteScore += diff * 9
    994 	blackScore += -diff * 9
    995 	whiteAdvantage += strings.Repeat("♛", utils.MaxInt(diff, 0))
    996 	blackAdvantage += strings.Repeat("♕", utils.MaxInt(-diff, 0))
    997 	diff = m[chess.WhiteRook] - m[chess.BlackRook]
    998 	whiteScore += diff * 5
    999 	blackScore += -diff * 5
   1000 	whiteAdvantage += strings.Repeat("♜", utils.MaxInt(diff, 0))
   1001 	blackAdvantage += strings.Repeat("♖", utils.MaxInt(-diff, 0))
   1002 	diff = m[chess.WhiteBishop] - m[chess.BlackBishop]
   1003 	whiteScore += diff * 3
   1004 	blackScore += -diff * 3
   1005 	whiteAdvantage += strings.Repeat("♝", utils.MaxInt(diff, 0))
   1006 	blackAdvantage += strings.Repeat("♗", utils.MaxInt(-diff, 0))
   1007 	diff = m[chess.WhiteKnight] - m[chess.BlackKnight]
   1008 	whiteScore += diff * 3
   1009 	blackScore += -diff * 3
   1010 	whiteAdvantage += strings.Repeat("♞", utils.MaxInt(diff, 0))
   1011 	blackAdvantage += strings.Repeat("♘", utils.MaxInt(-diff, 0))
   1012 	diff = m[chess.WhitePawn] - m[chess.BlackPawn]
   1013 	whiteScore += diff * 1
   1014 	blackScore += -diff * 1
   1015 	whiteAdvantage += strings.Repeat("♟", utils.MaxInt(diff, 0))
   1016 	blackAdvantage += strings.Repeat("♙", utils.MaxInt(-diff, 0))
   1017 	var whiteScoreLbl, blackScoreLbl string
   1018 	if whiteScore > 0 {
   1019 		whiteScoreLbl = fmt.Sprintf("+%d", whiteScore)
   1020 	}
   1021 	if blackScore > 0 {
   1022 		blackScoreLbl = fmt.Sprintf("+%d", blackScore)
   1023 	}
   1024 	if whiteAdvantage == "" {
   1025 		whiteAdvantage = "-"
   1026 	}
   1027 	if blackAdvantage == "" {
   1028 		blackAdvantage = "-"
   1029 	}
   1030 	return whiteAdvantage, whiteScoreLbl, blackAdvantage, blackScoreLbl
   1031 }
   1032 
   1033 type Score struct {
   1034 	Move     string
   1035 	BestMove string
   1036 	CP       int
   1037 	Mate     int
   1038 }
   1039 
   1040 type AnalyzeResult struct {
   1041 	WhiteAccuracy float64
   1042 	BlackAccuracy float64
   1043 	Scores        []Score
   1044 }
   1045 
   1046 func AnalyzeGame(gg *ChessGame, pgn string, t int64) (out AnalyzeResult, err error) {
   1047 	pgnOpt, _ := chess.PGN(strings.NewReader(pgn))
   1048 	g := chess.NewGame(pgnOpt)
   1049 	positions := g.Positions()
   1050 	nbPosition := len(positions)
   1051 
   1052 	if nbPosition <= 1 {
   1053 		return out, errors.New("no position to analyze")
   1054 	}
   1055 
   1056 	pubProgress := func(step int) {
   1057 		progress := ChessAnalyzeProgress{Step: step, Total: nbPosition}
   1058 		gg.SetAnalyzeProgress(progress)
   1059 		pubKey := "chess_analyze_progress_" + gg.Key
   1060 		ChessAnalyzeProgressPubSub.Pub(pubKey, progress)
   1061 	}
   1062 	defer func() {
   1063 		pubProgress(nbPosition)
   1064 		gg.UnsetAnalyzing()
   1065 	}()
   1066 
   1067 	eng, err := uci.New("stockfish")
   1068 	if err != nil {
   1069 		logrus.Error(err)
   1070 		return out, err
   1071 	}
   1072 	if err := eng.Run(uci.CmdUCI, uci.CmdIsReady, uci.CmdUCINewGame); err != nil {
   1073 		logrus.Error(err)
   1074 		return out, err
   1075 	}
   1076 	defer eng.Close()
   1077 
   1078 	scores := make([]Score, 0)
   1079 	cps := make([]int, 0)
   1080 
   1081 	t = utils.Clamp(t, 15, 60)
   1082 	moveTime := time.Duration((float64(t)/float64(len(positions)-1))*1000) * time.Millisecond
   1083 
   1084 	for idx, position := range positions {
   1085 		// First position is the board without any move played
   1086 		if idx == 0 {
   1087 			continue
   1088 		}
   1089 		cmdPos := uci.CmdPosition{Position: position}
   1090 		cmdGo := uci.CmdGo{MoveTime: moveTime}
   1091 		if err := eng.Run(cmdPos, cmdGo); err != nil {
   1092 			logrus.Error(err)
   1093 			mov := g.MoveHistory()[idx-1].Move
   1094 			moveStr := chess.AlgebraicNotation{}.Encode(positions[idx-1], mov)
   1095 			cps = append(cps, 0)
   1096 			scores = append(scores, Score{Move: moveStr})
   1097 			pubProgress(idx)
   1098 			continue
   1099 		}
   1100 		res := eng.SearchResults()
   1101 		cp := res.Info.Score.CP
   1102 		mate := res.Info.Score.Mate
   1103 		if idx%2 != 0 {
   1104 			cp *= -1
   1105 			mate *= -1
   1106 		}
   1107 		mov := g.MoveHistory()[idx-1].Move
   1108 		moveStr := chess.AlgebraicNotation{}.Encode(positions[idx-1], mov)
   1109 		bestMoveStr := chess.UCINotation{}.Encode(position, res.BestMove)
   1110 		cps = append(cps, cp)
   1111 		scores = append(scores, Score{Move: moveStr, BestMove: bestMoveStr, CP: cp, Mate: mate})
   1112 
   1113 		pubProgress(idx)
   1114 	}
   1115 
   1116 	//fmt.Println(strings.Join(s, ", "))
   1117 
   1118 	wa, ba := gameAccuracy(cps)
   1119 	return AnalyzeResult{
   1120 		Scores:        scores,
   1121 		WhiteAccuracy: wa,
   1122 		BlackAccuracy: ba,
   1123 	}, nil
   1124 }
   1125 
   1126 func mean(arr []float64) float64 {
   1127 	var sum float64
   1128 	for _, n := range arr {
   1129 		sum += n
   1130 	}
   1131 	return sum / float64(len(arr))
   1132 }
   1133 
   1134 func standardDeviation(arr []float64) float64 {
   1135 	nb := float64(len(arr))
   1136 	m := mean(arr)
   1137 	var acc float64
   1138 	for _, n := range arr {
   1139 		acc += (n - m) * (n - m)
   1140 	}
   1141 	return math.Sqrt(acc / nb)
   1142 }
   1143 
   1144 type Cp int
   1145 
   1146 const CpCeiling = Cp(1000)
   1147 const CpInitial = Cp(15)
   1148 
   1149 func (c Cp) ceiled() Cp {
   1150 	if c > CpCeiling {
   1151 		return CpCeiling
   1152 	} else if c < -CpCeiling {
   1153 		return -CpCeiling
   1154 	}
   1155 	return c
   1156 }
   1157 
   1158 func fromCentiPawns(cp Cp) float64 {
   1159 	return 50 + 50*winningChances(cp.ceiled())
   1160 }
   1161 
   1162 func winningChances(cp Cp) float64 {
   1163 	const MULTIPLIER = -0.00368208 // https://github.com/lichess-org/lila/pull/11148
   1164 	res := 2/(1+math.Exp(MULTIPLIER*float64(cp))) - 1
   1165 	out := math.Max(math.Min(res, 1), -1)
   1166 	return out
   1167 }
   1168 
   1169 func fromWinPercents(before, after float64) (accuracy float64) {
   1170 	if after >= before {
   1171 		return 100
   1172 	}
   1173 	winDiff := before - after
   1174 	raw := 103.1668100711649*math.Exp(-0.04354415386753951*winDiff) + -3.166924740191411
   1175 	raw += 1
   1176 	return math.Min(math.Max(raw, 0), 100)
   1177 }
   1178 
   1179 func calcWindows(allWinPercents []float64, windowSize int) (out [][]float64) {
   1180 	start := allWinPercents[:windowSize]
   1181 	m := utils.MinInt(windowSize, len(allWinPercents))
   1182 	for i := 0; i < m-2; i++ {
   1183 		out = append(out, start)
   1184 	}
   1185 
   1186 	for i := 0; i < len(allWinPercents)-(windowSize-1); i++ {
   1187 		curr := make([]float64, 0)
   1188 		for j := 0; j < windowSize; j++ {
   1189 			curr = append(curr, allWinPercents[i+j])
   1190 		}
   1191 		out = append(out, curr)
   1192 	}
   1193 	return
   1194 }
   1195 
   1196 func calcWeights(windows [][]float64) (out []float64) {
   1197 	out = make([]float64, len(windows))
   1198 	for i, w := range windows {
   1199 		out[i] = math.Min(math.Max(standardDeviation(w), 0.5), 12)
   1200 	}
   1201 	return
   1202 }
   1203 
   1204 func calcWeightedAccuracies(allWinPercents []float64, weights []float64) (float64, float64) {
   1205 	sw := calcWindows(allWinPercents, 2)
   1206 	whites := make([][2]float64, 0)
   1207 	blacks := make([][2]float64, 0)
   1208 	for i := 0; i < len(sw); i++ {
   1209 		prev, next := sw[i][0], sw[i][1]
   1210 		acc := prev
   1211 		acc1 := next
   1212 		if i%2 != 0 {
   1213 			acc, acc1 = acc1, acc
   1214 		}
   1215 		accuracy := fromWinPercents(acc, acc1)
   1216 		el := [2]float64{accuracy, weights[i]}
   1217 		if i%2 == 0 {
   1218 			whites = append(whites, el)
   1219 		} else {
   1220 			blacks = append(blacks, el)
   1221 		}
   1222 	}
   1223 
   1224 	www1 := weightedMean(whites)
   1225 	www2 := harmonicMean(whites)
   1226 	bbb1 := weightedMean(blacks)
   1227 	bbb2 := harmonicMean(blacks)
   1228 	return (www1 + www2) / 2, (bbb1 + bbb2) / 2
   1229 }
   1230 
   1231 func harmonicMean(arr [][2]float64) float64 {
   1232 	vs := make([]float64, 0)
   1233 	for _, v := range arr {
   1234 		vs = append(vs, v[0])
   1235 	}
   1236 	var sm float64
   1237 	for _, v := range vs {
   1238 		sm += 1 / math.Max(1, v)
   1239 	}
   1240 	return float64(len(vs)) / sm
   1241 }
   1242 
   1243 func weightedMean(a [][2]float64) float64 {
   1244 	vs := make([]float64, 0)
   1245 	ws := make([]float64, 0)
   1246 
   1247 	for _, v := range a {
   1248 		vs = append(vs, v[0])
   1249 		ws = append(ws, v[1])
   1250 	}
   1251 
   1252 	sumWeight, avg := 0.0, 0.0
   1253 	for i, v := range vs {
   1254 		if v == 0 {
   1255 			continue
   1256 		}
   1257 		sumWeight += ws[i]
   1258 		avg += v * ws[i]
   1259 	}
   1260 	avg /= sumWeight
   1261 	return avg
   1262 }
   1263 
   1264 func gameAccuracy(cps []int) (float64, float64) {
   1265 	cps = append([]int{int(CpInitial)}, cps...)
   1266 	var allWinPercents []float64
   1267 	for _, cp := range cps {
   1268 		allWinPercents = append(allWinPercents, fromCentiPawns(Cp(cp)))
   1269 	}
   1270 	windowSize := int(math.Min(math.Max(float64(len(cps)/10), 2), 8))
   1271 	windows := calcWindows(allWinPercents, windowSize)
   1272 	weights := calcWeights(windows)
   1273 	wa, ba := calcWeightedAccuracies(allWinPercents, weights)
   1274 	return wa, ba
   1275 }