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 ♔ ♔ 974 white chess queen ♕ U+2655 ♕ ♕ 975 white chess rook ♖ U+2656 ♖ ♖ 976 white chess bishop ♗ U+2657 ♗ ♗ 977 white chess knight ♘ U+2658 ♘ ♘ 978 white chess pawn ♙ U+2659 ♙ ♙ 979 black chess king ♚ U+265A ♚ ♚ 980 black chess queen ♛ U+265B ♛ ♛ 981 black chess rook ♜ U+265C ♜ ♜ 982 black chess bishop ♝ U+265D ♝ ♝ 983 black chess knight ♞ U+265E ♞ ♞ 984 black chess pawn ♟︎ U+265F ♟ ♟ 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 }