chess.go (29703B)
1 package handlers 2 3 import ( 4 "bytes" 5 "dkforest/pkg/config" 6 "dkforest/pkg/database" 7 "dkforest/pkg/hashset" 8 "dkforest/pkg/pubsub" 9 "dkforest/pkg/utils" 10 "dkforest/pkg/web/handlers/interceptors" 11 "dkforest/pkg/web/handlers/usersStreamsManager" 12 hutils "dkforest/pkg/web/handlers/utils" 13 "dkforest/pkg/web/handlers/utils/stream" 14 "encoding/json" 15 "errors" 16 "fmt" 17 "github.com/labstack/echo" 18 "github.com/notnil/chess" 19 "github.com/sirupsen/logrus" 20 "html/template" 21 "math" 22 "net/http" 23 "strconv" 24 "strings" 25 "time" 26 ) 27 28 type StylesBuilder []string 29 30 func (b *StylesBuilder) Append(v string) { 31 *b = append(*b, v) 32 } 33 34 func (b *StylesBuilder) Appendf(format string, a ...any) { 35 b.Append(fmt.Sprintf(format, a...)) 36 } 37 38 func (b *StylesBuilder) Build() string { 39 return fmt.Sprintf("<style>%s</style>", strings.Join(*b, " ")) 40 } 41 42 const ( 43 foolMateGame = "f3 e5 g4" 44 checkGame = "Nc3 h6 Nb5 h5" 45 promoWGame = "h4 g5 hxg5 h5 g6 h4 g7 h3" 46 promoBGame = "a3 c5 a4 c4 a5 c3 a6 cxb2 axb7" 47 kingSideCastleGame = "e3 e6 Be2 Be7 Nf3 Nf6" 48 queenSideCastleGame = "d4 d5 Qd3 Qd6 Bd2 Bd7 Nc3 Nc6" 49 enPassantGame = "d4 f6 d5 e5" 50 staleMateGame = "d4 d5 Nf3 Nf6 Bf4 Bg4 e3 e6 Bd3 c6 c3 Bd6 Bg3 Bxg3 hxg3 Nbd7 Nbd2 Ne4 Bxe4 dxe4 Nxe4 f5 Ned2 Qf6 Qa4 Nb6 Qb4 Qe7 Qxe7+ Kxe7 Ne5 Nd7 f3 Bh5 Rxh5 Nxe5 dxe5 g6 Rd1 Rad8 Nc4 Rxd1+ Kxd1 gxh5 Nd6 Rg8 Nxb7 Rxg3 Nc5 Rxg2 Kc1 Re2 e4 fxe4 Nxe4 h4 Ng5 h6 Nh3 Rh2 Nf4 h3 a4 Rh1+ Kc2 h2 Nh3 Rf1 f4 h1=Q f5 Qxh3 Kb3 Qxf5 a5 Qxe5 a6 Ra1 c4 Qe3+ Kb4 h5 b3 h4 c5 h3 Kc4 h2 Kb4" 51 ) 52 53 func ChessHandler(c echo.Context) error { 54 authUser := c.Get("authUser").(*database.User) 55 db := c.Get("database").(*database.DkfDB) 56 var data chessData 57 data.Games = interceptors.ChessInstance.GetGames() 58 59 if c.Request().Method == http.MethodPost { 60 data.Username = database.Username(c.Request().PostFormValue("username")) 61 data.Color = c.Request().PostFormValue("color") 62 data.Pgn = c.Request().PostFormValue("pgn") 63 player1 := *authUser 64 player2, err := db.GetUserByUsername(data.Username) 65 if err != nil { 66 data.Error = "invalid username" 67 return c.Render(http.StatusOK, "chess", data) 68 } 69 if _, err := interceptors.ChessInstance.NewGameWithPgn("", config.GeneralRoomID, player1, player2, data.Color, data.Pgn); err != nil { 70 data.Error = err.Error() 71 return c.Render(http.StatusOK, "chess", data) 72 } 73 return hutils.RedirectReferer(c) 74 } 75 76 return c.Render(http.StatusOK, "chess", data) 77 } 78 79 func ChessGameAnalyzeHandler(c echo.Context) error { 80 key := c.Param("key") 81 db := c.Get("database").(*database.DkfDB) 82 authUser := c.Get("authUser").(*database.User) 83 csrf, _ := c.Get("csrf").(string) 84 if !authUser.CanUseChessAnalyze { 85 return c.Redirect(http.StatusFound, "/") 86 } 87 g, err := interceptors.ChessInstance.GetGame(key) 88 if err != nil { 89 return c.Redirect(http.StatusFound, "/") 90 } 91 game := g.Game 92 if game.Outcome() == chess.NoOutcome { 93 return c.String(http.StatusOK, "no outcome") 94 } 95 96 if c.Request().Method == http.MethodGet && !g.IsAnalyzing() { 97 return c.HTML(http.StatusOK, ` 98 <style>html, body { background-color: #222; color: #eee; }</style> 99 <form method="post"> 100 <input type="hidden" name="csrf" value="`+csrf+`" /> 101 Total time (15-60): 102 <input type="number" name="t" value="15" min="15" max=60 /> 103 <button type="submit">Start analyze</button> 104 </form>`) 105 } 106 107 t := utils.Clamp(utils.ParseInt64OrDefault(c.Request().PostFormValue("t"), 15), 15, 60) 108 db.NewAudit(*authUser, fmt.Sprintf("start chess analyze: t=%d | key=%s", t, g.Key)) 109 110 if g.SetAnalyzing() { 111 go func() { 112 res, err := interceptors.AnalyzeGame(g, game.String(), t) 113 if err != nil { 114 logrus.Error(err) 115 return 116 } 117 g.DbChessGame.Stats, _ = json.Marshal(res) 118 g.DbChessGame.AccuracyWhite = res.WhiteAccuracy 119 g.DbChessGame.AccuracyBlack = res.BlackAccuracy 120 g.DbChessGame.DoSave(db) 121 }() 122 } 123 124 streamItem, err := stream.SetStreaming(c, authUser.ID, "analyze_"+key) 125 if err != nil { 126 return nil 127 } 128 defer streamItem.Cleanup() 129 130 sub := interceptors.ChessAnalyzeProgressPubSub.Subscribe([]string{"chess_analyze_progress_" + key}) 131 defer sub.Close() 132 133 renderProgress := func(progress interceptors.ChessAnalyzeProgress) { 134 _, _ = c.Response().Write([]byte(fmt.Sprintf(`<style>#progress:after { content: "PROGRESS: %d/%d" }</style>`, progress.Step, progress.Total))) 135 c.Response().Flush() 136 } 137 138 _, _ = c.Response().Write([]byte(`<style>html, body { background-color: #222; } 139 #progress { color: #eee; } 140 </style>`)) 141 _, _ = c.Response().Write([]byte(`<div id="progress"></div>`)) 142 progress := g.GetAnalyzeProgress() 143 renderProgress(progress) 144 145 defer func() { 146 _, _ = c.Response().Write([]byte(fmt.Sprintf(`<a href="/chess/%s">Back</a>`, g.Key))) 147 c.Response().Flush() 148 }() 149 150 Loop: 151 for { 152 select { 153 case <-streamItem.Quit: 154 break Loop 155 default: 156 } 157 158 if progress.Step > 0 && progress.Step == progress.Total { 159 break 160 } 161 162 _, progress, err = sub.ReceiveTimeout2(1*time.Second, streamItem.Quit) 163 if err != nil { 164 if errors.Is(err, pubsub.ErrCancelled) { 165 break Loop 166 } 167 continue 168 } 169 170 renderProgress(progress) 171 } 172 173 return nil 174 } 175 176 func ChessGameStatsHandler(c echo.Context) error { 177 key := c.Param("key") 178 authUser := c.Get("authUser").(*database.User) 179 csrf, _ := c.Get("csrf").(string) 180 g, err := interceptors.ChessInstance.GetGame(key) 181 if err != nil { 182 return c.NoContent(http.StatusOK) 183 } 184 htmlTmpl := hutils.HtmlCssReset + ` 185 <style> 186 .graph { 187 border: 0px solid #000; 188 background-color: #666; 189 box-sizing: border-box; 190 width: 100%; 191 table-layout: fixed; 192 } 193 .graph tr { height: 240px; } 194 .graph td { 195 height: inherit; 196 border-right: 0px solid #555; 197 } 198 .graph td:hover { 199 background-color: #5c5c5c; 200 } 201 .graph form { 202 height: 100%; 203 position: relative; 204 border: none; 205 } 206 .graph .column-wrapper-wrapper { 207 height: 100%; 208 width: 100%; 209 position: relative; 210 border: none; 211 background-color: transparent; 212 cursor: pointer; 213 padding: 0; 214 } 215 .graph .column-wrapper { 216 height: 50%; 217 width: 100%; 218 position: relative; 219 } 220 .graph .column { 221 position: absolute; 222 width: 100%; 223 box-sizing: border-box; 224 border-right: 1px solid #555; 225 } 226 </style> 227 <form method="post"> 228 <input type="hidden" name="csrf" value="{{ $.CSRF }}" /> 229 <table class="graph"> 230 <tr> 231 {{ range $idx, $el := .Stats.Scores }} 232 <td title="{{ $idx | fmtMove }} {{ $el.Move }} | Advantage: {{ if not $el.Mate }}{{ $el.CP | cp }}{{ else }}#{{ $el.Mate }}{{ end }}"> 233 {{ $el.BestMove | commentHTML }} 234 <button type="submit" name="move_idx" value="{{ $idx | plus }}" class="column-wrapper-wrapper" style="display: block;{{ if eq $.MoveIdx ($idx | plus) }} background-color: rgba(255, 255, 0, 0.2);{{ end }}"> 235 <div class="column-wrapper" style="border-bottom: 1px solid #333; box-sizing: border-box;"> 236 {{ if ge .CP 0 }} 237 <div class="column" style="height: {{ $el | renderCP "white" }}px; background-color: #eee; bottom: 0;"></div> 238 {{ end }} 239 </div> 240 <div class="column-wrapper"> 241 {{ if le .CP 0 }} 242 <div class="column" style="height: {{ $el | renderCP "black" }}px; background-color: #111;"></div> 243 {{ end }} 244 </div> 245 </button> 246 </td> 247 {{ end }} 248 </tr> 249 </table> 250 </form>` 251 252 data := map[string]any{ 253 "CSRF": csrf, 254 } 255 256 fns := template.FuncMap{ 257 "commentHTML": func(s string) template.HTML { 258 return template.HTML(fmt.Sprintf("<!-- %s -->", s)) 259 }, 260 "attr": func(s string) template.HTMLAttr { 261 return template.HTMLAttr(s) 262 }, 263 "plus": func(v int) int { return v + 1 }, 264 "pct": func(v float64) string { 265 return fmt.Sprintf("%.1f%%", v) 266 }, 267 "abs": func(v int) int { return int(math.Abs(float64(v))) }, 268 "cp": func(v int) string { 269 return fmt.Sprintf("%.2f", float64(v)/100) 270 }, 271 "fmtMove": func(idx int) string { 272 idx += 2 273 if idx%2 == 0 { 274 return fmt.Sprintf("%d.", idx/2) 275 } 276 return fmt.Sprintf("%d...", idx/2) 277 }, 278 "renderCP": func(color string, v interceptors.Score) int { 279 const maxH = 120 // Max graph height 280 const maxV = 1200 // Max cp value. Anything bigger should take 100% of graph height space. 281 absV := int(math.Abs(float64(v.CP))) 282 absV = utils.MinInt(absV, maxV) 283 absV = absV * maxH / maxV 284 if v.CP == 0 && v.Mate != 0 { 285 if (color == "white" && v.Mate > 0) || (color == "black" && v.Mate < 0) { 286 absV = maxH 287 } 288 } 289 return absV 290 }, 291 } 292 293 currMoveIdx := -1 294 v, err := c.Cookie("chess_" + key) 295 if err == nil { 296 currMoveIdx, _ = strconv.Atoi(v.Value) 297 } 298 299 moveIdx := currMoveIdx 300 301 var stats *interceptors.AnalyzeResult 302 if err := json.Unmarshal(g.DbChessGame.Stats, &stats); err != nil { 303 return hutils.RedirectReferer(c) 304 } 305 306 if c.Request().Method == http.MethodPost { 307 moveIdxStr := c.Request().PostFormValue("move_idx") 308 if moveIdxStr == "" { 309 moveIdx = -1 310 } 311 moveIdx, err = strconv.Atoi(moveIdxStr) 312 if err != nil { 313 moveIdx = -1 314 } 315 if moveIdx == -1 { 316 moveIdx = currMoveIdx 317 } 318 btnSubmit := c.Request().PostFormValue("btn_submit") 319 if moveIdx == -1 { 320 moveIdx = len(stats.Scores) 321 } 322 if btnSubmit == "prev_position" { 323 moveIdx -= 1 324 } else if btnSubmit == "next_position" { 325 moveIdx += 1 326 } 327 } 328 329 moveIdx = utils.Clamp(moveIdx, 0, len(stats.Scores)) 330 c.SetCookie(hutils.CreateCookie("chess_"+key, strconv.Itoa(moveIdx), utils.OneDaySecs)) 331 var bestMove string 332 if stats != nil { 333 if len(stats.Scores) > 0 { 334 if moveIdx > 0 { 335 bestMove = stats.Scores[moveIdx-1].BestMove 336 } 337 } 338 } 339 interceptors.ChessPubSub.Pub(key+"_"+authUser.Username.String(), interceptors.ChessMove{MoveIdx: moveIdx, BestMove: bestMove}) 340 341 data["Stats"] = stats 342 data["MoveIdx"] = moveIdx 343 344 var buf1 bytes.Buffer 345 if err := utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf1, data); err != nil { 346 logrus.Error(err) 347 } 348 return c.HTML(http.StatusOK, buf1.String()) 349 } 350 351 func ChessGameFormHandler(c echo.Context) error { 352 key := c.Param("key") 353 csrf, _ := c.Get("csrf").(string) 354 db := c.Get("database").(*database.DkfDB) 355 authUser := c.Get("authUser").(*database.User) 356 g, err := interceptors.ChessInstance.GetGame(key) 357 if err != nil { 358 return c.NoContent(http.StatusOK) 359 } 360 game := g.Game 361 isFlipped := g.IsBlack(authUser.ID) 362 363 if game.Outcome() != chess.NoOutcome { 364 if g.DbChessGame.Stats == nil { 365 return c.NoContent(http.StatusOK) 366 } 367 htmlTmpl := ` 368 <style> 369 button { 370 background-color: transparent; 371 position: absolute; 372 top: 0; 373 bottom: 0; 374 border: none; 375 } 376 #prev { 377 width: 30%; 378 cursor: pointer; 379 left: 0; 380 } 381 #prev:hover { 382 background-image: linear-gradient(to right, rgba(0, 0, 0, 0.3) , transparent); 383 } 384 #next { 385 width: 30%; 386 cursor: pointer; 387 right: 0; 388 } 389 #next:hover { 390 background-image: linear-gradient(to left, rgba(0, 0, 0, 0.3) , transparent); 391 } 392 </style> 393 <form method="post" target="iframeStats" action="/chess/{{ .Key }}/stats"> 394 <input type="hidden" name="csrf" value="{{ .CSRF }}" /> 395 <input type="hidden" name="move_idx" value="{{ .MoveIdx }}" /> 396 <div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0;"> 397 <button name="btn_submit" value="prev_position" type="submit" id="prev"></button> 398 <button name="btn_submit" value="next_position" type="submit" id="next"></button> 399 </div> 400 </form>` 401 402 data := map[string]any{ 403 "CSRF": csrf, 404 "MoveIdx": -1, 405 "Key": key, 406 } 407 408 var buf bytes.Buffer 409 _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) 410 411 return c.HTML(http.StatusOK, buf.String()) 412 } 413 414 if c.Request().Method == http.MethodPost { 415 if !g.IsPlayer(authUser.ID) { 416 return hutils.RedirectReferer(c) 417 } 418 419 btnSubmit := c.Request().PostFormValue("btn_submit") 420 if btnSubmit == "resign-cancel" { 421 return hutils.RedirectReferer(c) 422 423 } else if btnSubmit == "resign" { 424 425 htmlTmpl := `<form method="post"> 426 <input type="hidden" name="csrf" value="{{ .CSRF }}" /> 427 <div style="position: fixed; top: calc(50% - 80px); left: calc(50% - 100px); width: 200px; height: 80px; background-color: #444; border-radius: 5px;"> 428 <div style="padding: 10px;"> 429 <span style="margin-bottom: 5px; display: block; color: #eee;">Confirm resign:</span> 430 <button type="submit" name="btn_submit" value="resign-confirm" style="background-color: #aaa;">Confirm resign</button> 431 <button type="submit" name="btn_submit" value="resign-cancel" style="background-color: #aaa;">Cancel</button> 432 </div> 433 </div> 434 </form>` 435 436 data := map[string]any{ 437 "CSRF": csrf, 438 } 439 440 var buf bytes.Buffer 441 _ = utils.Must(template.New("").Parse(htmlTmpl)).Execute(&buf, data) 442 443 return c.HTML(http.StatusOK, buf.String()) 444 445 } else if btnSubmit == "resign-confirm" { 446 resignColor := utils.Ternary(isFlipped, chess.Black, chess.White) 447 game.Resign(resignColor) 448 g.DbChessGame.PGN = game.String() 449 g.DbChessGame.Outcome = game.Outcome().String() 450 g.DbChessGame.DoSave(db) 451 interceptors.ChessPubSub.Pub(key, interceptors.ChessMove{}) 452 453 } else { 454 if err := interceptors.ChessInstance.SendMove(key, authUser.ID, g, c); err != nil { 455 logrus.Error(err) 456 } 457 } 458 return hutils.RedirectReferer(c) 459 } 460 461 htmlTmpl := hutils.HtmlCssReset + interceptors.ChessCSS + ` 462 <form method="post"> 463 <input type="hidden" name="csrf" value="{{ .CSRF }}" /> 464 <input type="hidden" name="move_idx" value="{{ .MoveIdx }}" /> 465 <table class="newBoard"> 466 {{ range $row := .Rows }} 467 <tr> 468 {{ range $col := $.Cols }} 469 {{ $id := GetID $row $col }} 470 <td> 471 <input name="sq_{{ $id }}" id="sq_{{ $id }}" type="checkbox" value="1" /> 472 <label for="sq_{{ $id }}"></label> 473 </td> 474 {{ end }} 475 </tr> 476 {{ end }} 477 </table> 478 <div style="width: 100%; display: flex; margin: 5px 0;"> 479 <div><button type="submit" name="btn_submit" style="background-color: #aaa;">Move</button></div> 480 <div> 481 <span style="color: #aaa; margin-left: 20px;">Promo:</span> 482 <select name="promotion" style="background-color: #aaa;"> 483 <option value="queen">Queen</option> 484 <option value="rook">Rook</option> 485 <option value="knight">Knight</option> 486 <option value="bishop">Bishop</option> 487 </select> 488 </div> 489 <div style="margin-left: auto;"> 490 <button type="submit" name="btn_submit" value="resign" style="background-color: #aaa; margin-left: 50px;">Resign</button> 491 </div> 492 </div> 493 </form>` 494 495 data := map[string]any{ 496 "Rows": []int{0, 1, 2, 3, 4, 5, 6, 7}, 497 "Cols": []int{0, 1, 2, 3, 4, 5, 6, 7}, 498 "Key": key, 499 "CSRF": csrf, 500 "MoveIdx": len(g.Game.Moves()), 501 } 502 503 fns := template.FuncMap{ 504 "GetID": func(row, col int) int { return interceptors.GetID(row, col, isFlipped) }, 505 } 506 507 var buf bytes.Buffer 508 _ = utils.Must(template.New("").Funcs(fns).Parse(htmlTmpl)).Execute(&buf, data) 509 510 return c.HTML(http.StatusOK, buf.String()) 511 } 512 513 func squareCoord(sq chess.Square, isFlipped bool) (int, int) { 514 x, y := int(sq.File()), int(sq.Rank()) 515 if isFlipped { 516 x = 7 - x 517 } else { 518 y = 7 - y 519 } 520 return x, y 521 } 522 523 func initPiecesCache(game *chess.Game) map[string]chess.Square { 524 piecesCache := make(map[string]chess.Square) 525 pos := game.Positions()[0] 526 for i := 0; i < 64; i++ { 527 sq := chess.Square(i) 528 if pos.Board().Piece(sq) != chess.NoPiece { 529 piecesCache["piece_"+sq.String()] = sq 530 } 531 } 532 return piecesCache 533 } 534 535 const animationMs = 400 536 537 func animate(s1, s2 chess.Square, id string, isFlipped bool, animationIdx *int, styles *StylesBuilder) { 538 x1, y1 := squareCoord(s1, isFlipped) 539 x2, y2 := squareCoord(s2, isFlipped) 540 *animationIdx++ 541 animationName := fmt.Sprintf("move_anim_%d", *animationIdx) 542 keyframes := "@keyframes %s {" + 543 "from { left: calc(%d*12.5%%); top: calc(%d*12.5%%); }" + 544 " to { left: calc(%d*12.5%%); top: calc(%d*12.5%%); } }\n" 545 styles.Appendf(keyframes, animationName, x1, y1, x2, y2) 546 styles.Appendf("#%s { animation: %s %dms forwards; }\n", id, animationName, animationMs) 547 } 548 549 func ChessGameHandler(c echo.Context) error { 550 debugChess := true 551 552 authUser := c.Get("authUser").(*database.User) 553 key := c.Param("key") 554 555 g, _ := interceptors.ChessInstance.GetGame(key) 556 if g == nil { 557 if debugChess && config.Development.IsTrue() { 558 // Chess debug 559 db := c.Get("database").(*database.DkfDB) 560 user1, _ := db.GetUserByID(1) 561 user2, _ := db.GetUserByID(30814) 562 if _, err := interceptors.ChessInstance.NewGame(key, user1, user2, ""); err != nil { 563 logrus.Error(err) 564 return c.Redirect(http.StatusFound, "/") 565 } 566 var err error 567 g, err = interceptors.ChessInstance.GetGame(key) 568 if err != nil { 569 logrus.Error(err) 570 return c.Redirect(http.StatusFound, "/") 571 } 572 g.MakeMoves(kingSideCastleGame, db) 573 } else { 574 return c.Redirect(http.StatusFound, "/") 575 } 576 } 577 578 game := g.Game 579 580 // Keep track of where on the board a piece was last seen for this specific http stream 581 piecesCache1 := initPiecesCache(game) 582 583 isFlipped := authUser.ID == g.Player2.ID 584 585 isSpectator := !g.IsPlayer(authUser.ID) 586 if isSpectator && c.QueryParam("r") != "" { 587 isFlipped = true 588 } 589 590 send := func(s string) { 591 _, _ = c.Response().Write([]byte(s)) 592 } 593 594 // Keep track of "if the game was over" when we loaded the page 595 gameLoadedOver := game.Outcome() != chess.NoOutcome 596 597 streamItem, err := stream.SetStreaming(c, authUser.ID, key) 598 if err != nil { 599 return nil 600 } 601 defer streamItem.Cleanup() 602 603 send(hutils.HtmlCssReset) 604 send(`<style>html, body { background-color: #222; }</style>`) 605 606 authorizedChannels := make([]string, 0) 607 authorizedChannels = append(authorizedChannels, key) 608 authorizedChannels = append(authorizedChannels, key+"_"+authUser.Username.String()) 609 610 sub := interceptors.ChessPubSub.Subscribe(authorizedChannels) 611 defer sub.Close() 612 613 var card1 string 614 if isSpectator { 615 card1 = g.DrawSpectatorCard(0, key, isFlipped, authUser.ChessSoundsEnabled, authUser.CanUseChessAnalyze) 616 } else { 617 card1 = g.DrawPlayerCard(0, key, isFlipped, authUser.ChessSoundsEnabled, authUser.CanUseChessAnalyze) 618 } 619 send(card1) 620 621 go func(c echo.Context, key string, p1ID, p2ID database.UserID) { 622 var p1Online, p2Online bool 623 var once utils.Once 624 for { 625 select { 626 case <-once.After(100 * time.Millisecond): 627 case <-time.After(5 * time.Second): 628 case <-streamItem.Quit: 629 return 630 } 631 p1Count := usersStreamsManager.Inst.GetUserStreamsCountFor(p1ID, key) 632 p2Count := usersStreamsManager.Inst.GetUserStreamsCountFor(p2ID, key) 633 if p1Online && p1Count == 0 { 634 p1Online = false 635 send(`<style>#p1Status { background-color: darkred !important; }</style>`) 636 } else if !p1Online && p1Count > 0 { 637 p1Online = true 638 send(`<style>#p1Status { background-color: green !important; }</style>`) 639 } 640 if p2Online && p2Count == 0 { 641 p2Online = false 642 send(`<style>#p2Status { background-color: darkred !important; }</style>`) 643 } else if !p2Online && p2Count > 0 { 644 p2Online = true 645 send(`<style>#p2Status { background-color: green !important; }</style>`) 646 } 647 c.Response().Flush() 648 } 649 }(c, key, g.Player1.ID, g.Player2.ID) 650 651 var animationIdx int 652 Loop: 653 for { 654 select { 655 case <-streamItem.Quit: 656 break Loop 657 default: 658 } 659 660 // If we loaded the page and game was ongoing, we will stop the infinite loading page and display pgn 661 if game.Outcome() != chess.NoOutcome && !gameLoadedOver { 662 send(`<audio src="/public/sounds/chess/GenericNotify.ogg" autoplay></audio>`) 663 send(getWinnerStyle(game.Outcome())) 664 send(`<style>#outcome:after { content: "` + game.Outcome().String() + `" }</style>`) 665 send(`<style>.gameover { display: none !important; }</style>`) 666 send(`<div style="position: absolute; width: 200px; left: calc(50% - 100px); bottom: 20px">`) 667 send(`<textarea readonly>` + game.String() + `</textarea>`) 668 if authUser.CanUseChessAnalyze { 669 send(`<a style="color: #eee;" href="/chess/` + key + `/analyze">Analyse</a>`) 670 } 671 send(`</div>`) 672 break 673 } 674 675 _, payload, err := sub.ReceiveTimeout2(1*time.Second, streamItem.Quit) 676 if err != nil { 677 if errors.Is(err, pubsub.ErrCancelled) { 678 break Loop 679 } 680 continue 681 } 682 683 // If game was over when we loaded the page 684 if game.Outcome() != chess.NoOutcome && gameLoadedOver { 685 moveIdx := payload.MoveIdx 686 if moveIdx != 0 { 687 positions := game.Positions() 688 pos := positions[moveIdx] 689 moves := game.Moves()[:moveIdx] 690 lastMove := moves[len(moves)-1] 691 piecesCache := interceptors.InitPiecesCache(moves) 692 squareMap := pos.Board().SquareMap() 693 694 var bestMove *chess.Move 695 bestMoveStr := payload.BestMove 696 if bestMoveStr != "" { 697 bestMove, err = chess.UCINotation{}.Decode(pos, bestMoveStr) 698 if err != nil { 699 logrus.Error(err) 700 } 701 } 702 703 checkIDStr := "" 704 if lastMove.HasTag(chess.Check) && pos.Turn() == chess.White { 705 checkIDStr = interceptors.WhiteKingID 706 } else if lastMove.HasTag(chess.Check) && pos.Turn() == chess.Black { 707 checkIDStr = interceptors.BlackKingID 708 } 709 710 var styles StylesBuilder 711 renderAdvantages(&styles, pos) 712 renderHideAllPieces(&styles, piecesCache, piecesCache1, squareMap) 713 renderChecks(&styles, checkIDStr) 714 renderLastMove(&styles, *lastMove) 715 renderBestMove(&styles, bestMove, isFlipped) 716 renderShowVisiblePieceInPosition(&styles, &animationIdx, squareMap, piecesCache, piecesCache1, isFlipped) 717 renderWinnerBadges(&styles, moveIdx == len(positions)-1, game.Outcome()) 718 719 send(styles.Build()) 720 c.Response().Flush() 721 } 722 continue 723 } 724 725 if authUser.ChessSoundsEnabled { 726 if game.Method() != chess.Resignation { 727 isCapture := payload.Move.HasTag(chess.Capture) || payload.Move.HasTag(chess.EnPassant) 728 audioFile := utils.Ternary(isCapture, "Capture.ogg", "Move.ogg") 729 send(`<audio src="/public/sounds/chess/` + audioFile + `" autoplay></audio>`) 730 } 731 } 732 733 var styles StylesBuilder 734 735 animate(payload.Move.S1(), payload.Move.S2(), payload.IDStr1, isFlipped, &animationIdx, &styles) 736 737 if payload.Move.Promo() != chess.NoPieceType || payload.IDStr2 != "" { 738 // Ensure the capturing piece is draw above the one being captured 739 if payload.IDStr2 != "" { 740 styles.Appendf(`#%s { z-index: 2; }`, payload.IDStr2) 741 styles.Appendf(`#%s { z-index: 3; }`, payload.IDStr1) 742 } 743 // Wait until end of moving animation before hiding the captured piece or change promotion image 744 go func(payload interceptors.ChessMove, c echo.Context) { 745 select { 746 case <-time.After(animationMs * time.Millisecond): 747 case <-streamItem.Quit: 748 return 749 } 750 if payload.IDStr2 != "" { 751 send(fmt.Sprintf(`<style>#%s { display: none !important; }</style>`, payload.IDStr2)) 752 } 753 if payload.Move.Promo() != chess.NoPieceType { 754 pieceColor := utils.Ternary(payload.Move.S2().Rank() == chess.Rank8, chess.White, chess.Black) 755 promoImg := "/public/img/chess/" + pieceColor.String() + strings.ToUpper(payload.Move.Promo().String()) + ".png" 756 send(fmt.Sprintf(`<style>#%s { background-image: url("%s") !important; }</style>`, payload.IDStr1, promoImg)) 757 } 758 c.Response().Flush() 759 }(payload, c) 760 } 761 762 // Animate rook during castle 763 animateRookFn := animate 764 if payload.Move.HasTag(chess.KingSideCastle) { 765 if payload.Move.S1() == chess.E1 { 766 animateRookFn(chess.H1, chess.F1, interceptors.WhiteKingSideRookID, isFlipped, &animationIdx, &styles) 767 } else if payload.Move.S1() == chess.E8 { 768 animateRookFn(chess.H8, chess.F8, interceptors.BlackKingSideRookID, isFlipped, &animationIdx, &styles) 769 } 770 } else if payload.Move.HasTag(chess.QueenSideCastle) { 771 if payload.Move.S1() == chess.E1 { 772 animateRookFn(chess.A1, chess.D1, interceptors.WhiteQueenSideRookID, isFlipped, &animationIdx, &styles) 773 } else if payload.Move.S1() == chess.E8 { 774 animateRookFn(chess.A8, chess.D8, interceptors.BlackQueenSideRookID, isFlipped, &animationIdx, &styles) 775 } 776 } 777 // En passant 778 if payload.EnPassant != "" { 779 styles.Appendf(`#%s { display: none !important; }`, payload.EnPassant) 780 } 781 782 renderAdvantages(&styles, game.Position()) 783 renderLastMove(&styles, payload.Move) 784 renderChecks(&styles, payload.CheckIDStr) 785 786 send(styles.Build()) 787 788 c.Response().Flush() 789 } 790 return nil 791 } 792 793 func renderShowVisiblePieceInPosition(styles *StylesBuilder, animationIdx *int, 794 squareMap map[chess.Square]chess.Piece, piecesCache map[chess.Square]string, piecesCache1 map[string]chess.Square, isFlipped bool) { 795 oldSqs := hashset.New[chess.Square]() 796 for newSq := range squareMap { 797 sqID := piecesCache[newSq] // Get ID of piece on square newSq 798 currentSq := piecesCache1[sqID] // Get current square location of the piece 799 if currentSq != newSq { 800 oldSqs.Set(currentSq) 801 } 802 } 803 804 for newSq, piece := range squareMap { 805 sqID := piecesCache[newSq] // Get ID of piece on square newSq 806 currentSq := piecesCache1[sqID] // Get current square location of the piece 807 bStyle := fmt.Sprintf("#%s { display: block !important; ", sqID) 808 x, y := squareCoord(newSq, isFlipped) 809 bStyle += fmt.Sprintf("left: calc(%d*12.5%%); top: calc(%d*12.5%%); animation: none; ", x, y) 810 if strings.HasSuffix(sqID, "2") || strings.HasSuffix(sqID, "7") { 811 bStyle += "background-image: url(/public/img/chess/" + piece.Color().String() + strings.ToUpper(piece.Type().String()) + ".png) !important; " 812 } 813 bStyle += "}\n" 814 styles.Append(bStyle) 815 if currentSq != newSq && !oldSqs.Contains(newSq) { 816 animate(currentSq, newSq, sqID, isFlipped, animationIdx, styles) // Move piece from current square to the new square where we want it to be 817 } 818 piecesCache1[sqID] = newSq // Update cache of location of the piece 819 } 820 } 821 822 func renderAdvantages(styles *StylesBuilder, pos *chess.Position) { 823 whiteAdv, whiteScore, blackAdv, blackScore := interceptors.CalcAdvantage(pos) 824 styles.Appendf(`#white-advantage:before { content: "%s" !important; }`, whiteAdv) 825 styles.Appendf(`#white-advantage .score:after { content: "%s" !important; }`, whiteScore) 826 styles.Appendf(`#black-advantage:before { content: "%s" !important; }`, blackAdv) 827 styles.Appendf(`#black-advantage .score:after { content: "%s" !important; }`, blackScore) 828 } 829 830 func renderHideAllPieces(styles *StylesBuilder, piecesCache map[chess.Square]string, piecesCache1 map[string]chess.Square, squareMap map[chess.Square]chess.Piece) { 831 toHideMap := make(map[string]struct{}) 832 for id, _ := range piecesCache1 { 833 toHideMap[id] = struct{}{} 834 } 835 for sq := range squareMap { 836 idOnSq, _ := piecesCache[sq] 837 delete(toHideMap, idOnSq) 838 } 839 toHide := make([]string, 0) 840 for id := range toHideMap { 841 toHide = append(toHide, "#"+id) 842 } 843 styles.Appendf(`%s { display: none !important; }`, strings.Join(toHide, ", ")) 844 } 845 846 func renderWinnerBadges(styles *StylesBuilder, isLastPosition bool, outcome chess.Outcome) { 847 styles.Append(`#piece_e8_draw, #piece_e1_draw, #piece_e8_winner, #piece_e1_loser, #piece_e8_loser, #piece_e1_winner { display: none !important; }`) 848 if isLastPosition { 849 styles.Append(getWinnerStyle(outcome)) 850 } 851 } 852 853 func getWinnerStyle(outcome chess.Outcome) (out string) { 854 if outcome == chess.WhiteWon { 855 out += `<style>#piece_e1_winner, #piece_e8_loser { animation: 2s 0s forwards winner_anim }</style>` 856 out += `<style>#piece_e1_winner, #piece_e8_loser { display: block !important; }</style>` 857 } else if outcome == chess.BlackWon { 858 out += `<style>#piece_e8_winner, #piece_e1_loser { animation: 2s 0s forwards winner_anim }</style>` 859 out += `<style>#piece_e8_winner, #piece_e1_loser { display: block !important; }</style>` 860 } else if outcome == chess.Draw { 861 out += `<style>#piece_e8_draw, #piece_e1_draw { animation: 2s 0s forwards winner_anim }</style>` 862 out += `<style>#piece_e8_draw, #piece_e1_draw { display: block !important; }</style>` 863 } 864 return 865 } 866 867 func calcDisc(x1, y1, x2, y2 int) (d int, isDiag, isLine bool) { 868 dx := int(math.Abs(float64(x2 - x1))) 869 dy := int(math.Abs(float64(y2 - y1))) 870 if x1 == x2 { 871 d = dy 872 isLine = true 873 } else if y1 == y2 { 874 d = dx 875 isLine = true 876 } else { 877 d = dx + dy 878 } 879 isDiag = dx == dy 880 return 881 } 882 883 func arrow(s1, s2 chess.Square, isFlipped bool) (out string) { 884 cx1, cy1 := squareCoord(s1, isFlipped) 885 cx2, cy2 := squareCoord(s2, isFlipped) 886 dist, isDiag, isLine := calcDisc(cx1, cy1, cx2, cy2) 887 a := math.Atan2(float64(cy1-cy2), float64(cx1-cx2)) + 3*math.Pi/2 888 out += fmt.Sprintf("#arrow { "+ 889 "display: block !important; "+ 890 "transform: rotate(%.9frad) !important; "+ 891 "top: calc(%d*12.5%% + (12.5%%/2)) !important; "+ 892 "left: calc(%d*12.5%% + (12.5%%/2) - 6.25%%) !important; "+ 893 "} ", a, cy2, cx2) 894 var h string 895 if isDiag { 896 h = fmt.Sprintf("calc(%d*141.42%% + 42.43%% + 55%%)", dist/2-1) 897 } else if isLine { 898 h = fmt.Sprintf("calc(%d*100%% + 55%%)", dist-1) 899 } else { 900 h = fmt.Sprintf("calc(223.60%% - 45%%)") 901 } 902 out += fmt.Sprintf("#arrow .rectangle { height: %s !important; }", h) 903 return 904 } 905 906 func renderBestMove(styles *StylesBuilder, bestMove *chess.Move, isFlipped bool) { 907 if bestMove != nil { 908 s1 := bestMove.S1() 909 s2 := bestMove.S2() 910 arrowStyle := arrow(s1, s2, isFlipped) 911 styles.Append(arrowStyle) 912 } else { 913 styles.Append(`#arrow { display: none !important; }`) 914 } 915 } 916 917 func renderLastMove(styles *StylesBuilder, lastMove chess.Move) { 918 styles.Appendf(`.square { background-color: transparent !important; }`) 919 styles.Appendf(`.square_%d, .square_%d { background-color: %s !important; }`, 920 int(lastMove.S1()), int(lastMove.S2()), interceptors.LastMoveColor) 921 } 922 923 func renderChecks(styles *StylesBuilder, checkID string) { 924 // Reset kings background to transparent 925 styles.Appendf(`#%s, #%s { background-color: transparent !important; }`, interceptors.WhiteKingID, interceptors.BlackKingID) 926 // Render "checks" red background 927 if checkID != "" { 928 styles.Appendf(`#%s { background-color: %s !important; }`, checkID, interceptors.CheckColor) 929 } 930 } 931 932 func ChessAnalyzeHandler(c echo.Context) error { 933 authUser := c.Get("authUser").(*database.User) 934 if !authUser.CanUseChessAnalyze { 935 return c.Redirect(http.StatusFound, "/") 936 } 937 var data chessAnalyzeData 938 data.Pgn = c.Request().PostFormValue("pgn") 939 return c.Render(http.StatusOK, "chess-analyze", data) 940 }