diff --git a/modules/study/src/main/StudyMultiBoard.scala b/modules/study/src/main/StudyMultiBoard.scala index 972a10704e919..38371c5c37cd9 100644 --- a/modules/study/src/main/StudyMultiBoard.scala +++ b/modules/study/src/main/StudyMultiBoard.scala @@ -1,7 +1,7 @@ package lila.study import BSONHandlers.given -import chess.{ ByColor, Color, Outcome } +import chess.{ ByColor, Centis, Color, Outcome, Ply } import chess.format.pgn.Tags import chess.format.{ Fen, Uci } import com.github.blemale.scaffeine.AsyncLoadingCache @@ -67,13 +67,42 @@ final class StudyMultiBoard( "lang" -> "js", "args" -> $arr("$root", "$tags"), "body" -> """function(root, tags) { - |tags = tags.filter(t => t.startsWith('White') || t.startsWith('Black') || t.startsWith('Result')); - |const node = tags.length ? Object.keys(root).reduce(([path, node], i) => (root[i].p > node.p && i.startsWith(path)) ? [i, root[i]] : [path, node], ['', root['_']])[1] : root['_']; - |return {node:{fen:node.f,uci:node.u},tags} }""".stripMargin + tags = tags.filter(t => t.startsWith('White') || t.startsWith('Black') || t.startsWith('Result')); + const [node, clockTicking] = tags.length ? + Object.keys(root).reduce( + ([node, clockTicking, path, pathTicking], i) => { + if (root[i].p > node.p && i.startsWith(path)) { + clockTicking = node; + pathTicking = path; + node = root[i]; + path = i; + } else if (clockTicking && root[i].p > clockTicking.p && i.startsWith(pathTicking)) { + clockTicking = root[i]; + pathTicking = i; + } + return [node, clockTicking, path, pathTicking] + }, + [root['_'], undefined, '', undefined] + ).slice(0, 2) : [root['_'], undefined]; + const [whiteClock, blackClock] = clockTicking ? node.f.includes(" b") ? [node.l, clockTicking.l] : [clockTicking.l, node.l] : [undefined, undefined] + + return { + node: { + fen: node.f, + uci: node.u, + }, + tags, + clocks: { + black: blackClock, + white: whiteClock, + } + } + }""".stripMargin ) ), "orientation" -> "$setup.orientation", - "name" -> true + "name" -> true, + "lastMoveAt" -> "$relay.lastMoveAt" ) ) ) @@ -84,18 +113,23 @@ final class StudyMultiBoard( doc <- r id <- doc.getAsOpt[StudyChapterId]("_id") name <- doc.getAsOpt[StudyChapterName]("name") - comp <- doc.getAsOpt[Bdoc]("comp") - node <- comp.getAsOpt[Bdoc]("node") - fen <- node.getAsOpt[Fen.Epd]("fen") - lastMove = node.getAsOpt[Uci]("uci") - tags = comp.getAsOpt[Tags]("tags") + lastMoveAt = doc.getAsOpt[Instant]("lastMoveAt") + comp <- doc.getAsOpt[Bdoc]("comp") + node <- comp.getAsOpt[Bdoc]("node") + fen <- node.getAsOpt[Fen.Epd]("fen") + clocks <- comp.getAsOpt[Bdoc]("clocks") + lastMove = node.getAsOpt[Uci]("uci") + tags = comp.getAsOpt[Tags]("tags") + blackClock = clocks.getAsOpt[Centis]("black") + whiteClock = clocks.getAsOpt[Centis]("white") yield ChapterPreview( id = id, name = name, - players = tags flatMap ChapterPreview.players, + players = tags flatMap ChapterPreview.players(blackClock = blackClock, whiteClock = whiteClock), orientation = doc.getAsOpt[Color]("orientation") | Color.White, fen = fen, lastMove = lastMove, + lastMoveAt = lastMoveAt, playing = lastMove.isDefined && tags.flatMap(_(_.Result)).has("*"), outcome = tags.flatMap(_.outcome) ) @@ -108,13 +142,14 @@ final class StudyMultiBoard( .obj("name" -> p.name) .add("title" -> p.title) .add("rating" -> p.rating) + .add("clock" -> p.clock) } given Writes[ChapterPreview.Players] = Writes[ChapterPreview.Players] { players => Json.obj("white" -> players.white, "black" -> players.black) } - given Writes[Outcome] = writeAs(_.toString) + given Writes[Outcome] = writeAs(_.toString.replace("1/2", "½")) given Writes[ChapterPreview] = Json.writes @@ -127,21 +162,22 @@ object StudyMultiBoard: orientation: Color, fen: Fen.Epd, lastMove: Option[Uci], + lastMoveAt: Option[Instant], playing: Boolean, outcome: Option[Outcome] ) object ChapterPreview: - case class Player(name: String, title: Option[String], rating: Option[Int]) + case class Player(name: String, title: Option[String], rating: Option[Int], clock: Option[Centis]) type Players = ByColor[Player] - def players(tags: Tags): Option[Players] = + def players(blackClock: Option[Centis], whiteClock: Option[Centis])(tags: Tags): Option[Players] = for wName <- tags(_.White) bName <- tags(_.Black) yield ByColor( - white = Player(wName, tags(_.WhiteTitle), tags(_.WhiteElo).flatMap(_.toIntOption)), - black = Player(bName, tags(_.BlackTitle), tags(_.BlackElo).flatMap(_.toIntOption)) + white = Player(wName, tags(_.WhiteTitle), tags(_.WhiteElo).flatMap(_.toIntOption), whiteClock), + black = Player(bName, tags(_.BlackTitle), tags(_.BlackElo).flatMap(_.toIntOption), blackClock) ) diff --git a/ui/analyse/src/explorer/explorerCtrl.ts b/ui/analyse/src/explorer/explorerCtrl.ts index ede78696463ed..d76e781238387 100644 --- a/ui/analyse/src/explorer/explorerCtrl.ts +++ b/ui/analyse/src/explorer/explorerCtrl.ts @@ -1,10 +1,11 @@ import { Prop, prop, defined } from 'common'; import { storedBooleanProp } from 'common/storage'; +import { fenColor } from 'common/mini-game'; import debounce from 'common/debounce'; import { sync, Sync } from 'common/sync'; import { opposite } from 'chessground/util'; import * as xhr from './explorerXhr'; -import { winnerOf, colorOf } from './explorerUtil'; +import { winnerOf } from './explorerUtil'; import * as gameUtil from 'game'; import AnalyseCtrl from '../ctrl'; import { Hovering, ExplorerData, ExplorerDb, OpeningData, SimpleTablebaseHit, ExplorerOpts } from './interfaces'; @@ -217,7 +218,7 @@ export default class ExplorerCtrl { return { fen, best: move && move.uci, - winner: res.checkmate ? opposite(colorOf(fen)) : res.stalemate ? undefined : winnerOf(fen, move!), + winner: res.checkmate ? opposite(fenColor(fen)) : res.stalemate ? undefined : winnerOf(fen, move!), } as SimpleTablebaseHit; }; } diff --git a/ui/analyse/src/explorer/explorerUtil.ts b/ui/analyse/src/explorer/explorerUtil.ts index 11a3e0c682614..8663ede023aa6 100644 --- a/ui/analyse/src/explorer/explorerUtil.ts +++ b/ui/analyse/src/explorer/explorerUtil.ts @@ -1,14 +1,11 @@ import { TablebaseMoveStats } from './interfaces'; import { opposite } from 'chessops/util'; +import { fenColor } from 'common/mini-game'; import { VNode } from 'snabbdom'; import AnalyseCtrl from '../ctrl'; -export function colorOf(fen: Fen): Color { - return fen.split(' ')[1] === 'w' ? 'white' : 'black'; -} - export function winnerOf(fen: Fen, move: TablebaseMoveStats): Color | undefined { - const stm = colorOf(fen); + const stm = fenColor(fen); if (move.checkmate || move.variant_loss || (move.dtz && move.dtz < 0)) return stm; if (move.variant_win || (move.dtz && move.dtz > 0)) return opposite(stm); return undefined; diff --git a/ui/analyse/src/study/interfaces.ts b/ui/analyse/src/study/interfaces.ts index 042b9e1697a5f..3805af067ab93 100644 --- a/ui/analyse/src/study/interfaces.ts +++ b/ui/analyse/src/study/interfaces.ts @@ -158,7 +158,7 @@ export interface StudyChapterMeta { id: string; name: string; ongoing?: boolean; - res?: string; + res?: '1-0' | '0-1' | '½-½' | '*'; } export interface StudyChapterConfig extends StudyChapterMeta { @@ -234,14 +234,16 @@ export interface ChapterPreview { orientation: Color; fen: string; lastMove?: string; + lastMoveAt?: number; playing: boolean; - outcome?: '1-0' | '0-1' | '1/2-1/2'; + outcome?: '1-0' | '0-1' | '½-½'; } export interface ChapterPreviewPlayer { name: string; title?: string; rating?: number; + clock?: number; } export type Orientation = 'black' | 'white' | 'auto'; diff --git a/ui/analyse/src/study/multiBoard.ts b/ui/analyse/src/study/multiBoard.ts index 8d342cb53d85a..d686932e7cf95 100644 --- a/ui/analyse/src/study/multiBoard.ts +++ b/ui/analyse/src/study/multiBoard.ts @@ -1,10 +1,12 @@ import debounce from 'common/debounce'; +import { renderClock, fenColor } from 'common/mini-game'; import { bind, MaybeVNodes } from 'common/snabbdom'; import { spinnerVdom as spinner } from 'common/spinner'; import { h, VNode } from 'snabbdom'; import { multiBoard as xhrLoad } from './studyXhr'; -import { opposite } from 'chessground/util'; -import { StudyCtrl, ChapterPreview, ChapterPreviewPlayer, Position } from './interfaces'; +import { opposite as CgOpposite } from 'chessground/util'; +import { opposite as oppositeColor } from 'chessops/util'; +import { StudyCtrl, ChapterPreview, ChapterPreviewPlayer, Position, StudyChapterMeta } from './interfaces'; export class MultiBoardCtrl { loading = false; @@ -19,10 +21,28 @@ export class MultiBoardCtrl { if (cp?.playing) { cp.fen = node.fen; cp.lastMove = node.uci; + const playerWhoMoved = cp.players && cp.players[oppositeColor(fenColor(cp.fen))]; + playerWhoMoved && (playerWhoMoved.clock = node.clock); + // at this point `(cp: ChapterPreview).lastMoveAt` becomes outdated but should be ok since not in use anymore + // to mitigate bad usage, setting it as `undefined` + cp.lastMoveAt = undefined; this.redraw(); } }; + addResult = (metas: StudyChapterMeta[]) => { + let changed = false; + for (const meta of metas) { + const cp = this.pager && this.pager.currentPageResults.find(cp => cp.id == meta.id); + if (cp?.playing) { + const oldOutcome = cp.outcome; + cp.outcome = meta.res !== '*' ? meta.res : undefined; + changed = changed || cp.outcome !== oldOutcome; + } + } + if (changed) this.redraw(); + }; + reload = (onInsert?: boolean) => { if (this.pager && !onInsert) { this.loading = true; @@ -153,16 +173,29 @@ const makePreview = (study: StudyCtrl) => (preview: ChapterPreview) => }, postpatch(old, vnode) { if (old.data!.fen !== preview.fen) { - lichess.miniGame.update(vnode.elm as HTMLElement, { - lm: preview.lastMove!, - fen: preview.fen, - }); + if (preview.outcome) { + lichess.miniGame.finish( + vnode.elm as HTMLElement, + preview.outcome === '1-0' ? 'white' : preview.outcome === '0-1' ? 'black' : undefined + ); + } else { + lichess.miniGame.update(vnode.elm as HTMLElement, { + lm: preview.lastMove!, + fen: preview.fen, + wc: computeTimeLeft(preview, 'white'), + bc: computeTimeLeft(preview, 'black'), + }); + } } vnode.data!.fen = preview.fen; }, }, }, - [boardPlayer(preview, opposite(preview.orientation)), h('span.cg-wrap'), boardPlayer(preview, preview.orientation)] + [ + boardPlayer(preview, CgOpposite(preview.orientation)), + h('span.cg-wrap'), + boardPlayer(preview, preview.orientation), + ] ); const userName = (u: ChapterPreviewPlayer) => (u.title ? [h('span.utitle', u.title), ' ' + u.name] : [u.name]); @@ -179,11 +212,25 @@ function renderPlayer(player: ChapterPreviewPlayer | undefined): VNode | undefin ); } +const computeTimeLeft = (preview: ChapterPreview, color: Color): number | undefined => { + const player = preview.players && preview.players[color]; + if (player && player.clock) { + if (preview.lastMoveAt && fenColor(preview.fen) == color) { + const spent = (Date.now() - preview.lastMoveAt) / 1000; + return Math.max(0, player.clock / 100 - spent); + } else { + return player.clock / 100; + } + } else { + return; + } +}; + const boardPlayer = (preview: ChapterPreview, color: Color) => { const player = preview.players && preview.players[color]; const result = preview.outcome?.split('-')[color === 'white' ? 0 : 1]; - return h('span.mini-game__player', [ - h('span.mini-game__user', [renderPlayer(player)]), - result && h('span.mini-game__result', result.replace('1/2', '½')), - ]); + const resultNode = result && h('span.mini-game__result', result); + const timeleft = computeTimeLeft(preview, color); + const clock = timeleft && renderClock(color, timeleft); + return h('span.mini-game__player', [h('span.mini-game__user', [renderPlayer(player)]), resultNode ?? clock]); }; diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts index 3ef14f132e317..faea75c5405b5 100644 --- a/ui/analyse/src/study/studyCtrl.ts +++ b/ui/analyse/src/study/studyCtrl.ts @@ -530,6 +530,7 @@ export default function ( }, chapters(d) { chapters.list(d); + if (vm.toolTab() == 'multiBoard' || (relay && relay.tourShow.active)) multiBoard.addResult(d); if (!currentChapter()) { vm.chapterId = d[0].id; if (!vm.mode.sticky) xhrReload(); diff --git a/ui/common/src/mini-game.ts b/ui/common/src/mini-game.ts new file mode 100644 index 0000000000000..2284cce2bead4 --- /dev/null +++ b/ui/common/src/mini-game.ts @@ -0,0 +1,11 @@ +import { h } from 'snabbdom'; + +export const fenColor = (fen: string) => (fen.includes(' w') ? 'white' : 'black'); + +export const renderClock = (color: Color, time: number) => + h(`span.mini-game__clock.mini-game__clock--${color}`, { + attrs: { + 'data-time': time, + 'data-managed': 1, + }, + }); diff --git a/ui/simul/src/view/pairings.ts b/ui/simul/src/view/pairings.ts index 0e603b799f280..1d57d829e3d8e 100644 --- a/ui/simul/src/view/pairings.ts +++ b/ui/simul/src/view/pairings.ts @@ -1,5 +1,6 @@ import { h } from 'snabbdom'; import { onInsert } from 'common/snabbdom'; +import { renderClock } from 'common/mini-game'; import SimulCtrl from '../ctrl'; import { Pairing } from '../interfaces'; import { opposite } from 'chessground/util'; @@ -8,14 +9,6 @@ export default function (ctrl: SimulCtrl) { return h('div.game-list.now-playing.box__pad', ctrl.data.pairings.map(miniPairing(ctrl))); } -const renderClock = (color: Color, time: number) => - h(`span.mini-game__clock.mini-game__clock--${color}`, { - attrs: { - 'data-time': time, - 'data-managed': 1, - }, - }); - const miniPairing = (ctrl: SimulCtrl) => (pairing: Pairing) => { const game = pairing.game, player = pairing.player; diff --git a/ui/site/src/component/mini-game.ts b/ui/site/src/component/mini-game.ts index c00d0c2f0eef2..f89b0a1fe15ec 100644 --- a/ui/site/src/component/mini-game.ts +++ b/ui/site/src/component/mini-game.ts @@ -1,10 +1,9 @@ import { uciToMove } from 'chessground/util'; +import { fenColor } from 'common/mini-game'; import * as domData from 'common/data'; import clockWidget from './clock-widget'; import StrongSocket from './socket'; -const fenColor = (fen: string) => (fen.indexOf(' b') > 0 ? 'black' : 'white'); - export const init = (node: HTMLElement) => { if (!window.Chessground) setTimeout(() => init(node), 200); else { @@ -55,21 +54,21 @@ export const update = (node: HTMLElement, data: MiniGameUpdateData) => { lastMove: uciToMove(lm), }); const turnColor = fenColor(data.fen); - const renderClock = (time: number | undefined, color: Color) => { + const updateClock = (time: number | undefined, color: Color) => { if (!isNaN(time!)) clockWidget($el[0]?.querySelector('.mini-game__clock--' + color) as HTMLElement, { time: time!, pause: color != turnColor || !clockIsRunning(data.fen, color), }); }; - renderClock(data.wc, 'white'); - renderClock(data.bc, 'black'); + updateClock(data.wc, 'white'); + updateClock(data.bc, 'black'); }; -export const finish = (node: HTMLElement, win?: string) => +export const finish = (node: HTMLElement, win?: 'black' | 'white') => ['white', 'black'].forEach(color => { const $clock = $(node).find('.mini-game__clock--' + color); // don't interfere with snabbdom clocks if (!$clock.data('managed')) - $clock.replaceWith(`${win ? (win == color[0] ? 1 : 0) : '½'}`); + $clock.replaceWith(`${win ? (win === color[0] ? 1 : 0) : '½'}`); }); diff --git a/ui/swiss/src/view/boards.ts b/ui/swiss/src/view/boards.ts index 2b74b64e7ac2d..231aae788e7ca 100644 --- a/ui/swiss/src/view/boards.ts +++ b/ui/swiss/src/view/boards.ts @@ -1,4 +1,5 @@ import { Board, SwissOpts } from '../interfaces'; +import { renderClock } from 'common/mini-game'; import { h, VNode } from 'snabbdom'; import { opposite } from 'chessground/util'; import { player as renderPlayer } from './util'; @@ -44,12 +45,7 @@ function boardPlayer(board: Board, color: Color, opts: SwissOpts) { return h('span.mini-game__player', [ h('span.mini-game__user', [h('strong', '#' + player.rank), renderPlayer(player, true, opts.showRatings)]), board.clock - ? h(`span.mini-game__clock.mini-game__clock--${color}`, { - attrs: { - 'data-time': board.clock[color], - 'data-managed': 1, - }, - }) + ? renderClock(color, board.clock[color]) : h('span.mini-game__result', board.winner ? (board.winner == color ? 1 : 0) : '½'), ]); }