dkforest

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

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 }