From 80c8355ab2bcd1f3e488528da5826a7516dceae1 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Wed, 29 Apr 2020 18:00:47 -0600 Subject: [PATCH] swiss WIP --- bin/mongodb/swiss.js | 1 + modules/swiss/src/main/BsonHandlers.scala | 4 +- modules/swiss/src/main/Env.scala | 18 ++- modules/swiss/src/main/Swiss.scala | 12 +- modules/swiss/src/main/SwissApi.scala | 5 + modules/swiss/src/main/SwissJson.scala | 122 +++++++++++++++------ modules/swiss/src/main/model.scala | 14 ++- modules/tournament/src/main/JsonView.scala | 4 +- ui/swiss/gulpfile.js | 2 +- ui/swiss/src/boot.ts | 7 +- ui/swiss/src/ctrl.ts | 50 +++++++++ ui/swiss/src/interfaces.ts | 116 +++++++------------- ui/swiss/src/main.ts | 4 +- ui/swiss/src/socket.ts | 27 +++++ ui/swiss/src/xhr.ts | 81 ++++++++++++++ 15 files changed, 330 insertions(+), 137 deletions(-) create mode 100644 ui/swiss/src/ctrl.ts create mode 100644 ui/swiss/src/socket.ts create mode 100644 ui/swiss/src/xhr.ts diff --git a/bin/mongodb/swiss.js b/bin/mongodb/swiss.js index ad73c26209..a24b10ef74 100644 --- a/bin/mongodb/swiss.js +++ b/bin/mongodb/swiss.js @@ -1,4 +1,5 @@ db.swiss.ensureIndex({teamId:1,startsAt:1}) +db.swiss_player.ensureIndex({s:1,u:1}) db.swiss_player.ensureIndex({s:1,c:-1}) db.swiss_pairing.ensureIndex({s:1,r:1}) db.swiss_pairing.ensureIndex({s:1,u:1,r:1}) diff --git a/modules/swiss/src/main/BsonHandlers.scala b/modules/swiss/src/main/BsonHandlers.scala index 1e2521501f..94ee29dc2d 100644 --- a/modules/swiss/src/main/BsonHandlers.scala +++ b/modules/swiss/src/main/BsonHandlers.scala @@ -50,7 +50,6 @@ private object BsonHandlers { implicit val playerNumberHandler = intAnyValHandler[SwissPlayer.Number](_.value, SwissPlayer.Number.apply) implicit val roundNumberHandler = intAnyValHandler[SwissRound.Number](_.value, SwissRound.Number.apply) implicit val swissIdHandler = stringAnyValHandler[Swiss.Id](_.value, Swiss.Id.apply) - implicit val pairingIdHandler = stringAnyValHandler[SwissPairing.Id](_.value, SwissPairing.Id.apply) implicit val playerIdHandler = stringAnyValHandler[SwissPlayer.Id](_.value, SwissPlayer.Id.apply) implicit val playerHandler = new BSON[SwissPlayer] { @@ -81,10 +80,9 @@ private object BsonHandlers { r.get[List[SwissPlayer.Number]]("u") match { case List(white, black) => SwissPairing( - _id = r.get[SwissPairing.Id]("_id"), + _id = r str "_id", swissId = r.get[Swiss.Id]("s"), round = r.get[SwissRound.Number]("r"), - gameId = r str "g", white = white, black = black, winner = r boolO "w" map { diff --git a/modules/swiss/src/main/Env.scala b/modules/swiss/src/main/Env.scala index 00c1c9b03d..49617a3365 100644 --- a/modules/swiss/src/main/Env.scala +++ b/modules/swiss/src/main/Env.scala @@ -7,17 +7,27 @@ import lila.common.config._ @Module final class Env( + remoteSocketApi: lila.socket.RemoteSocket, db: lila.db.Db, + chatApi: lila.chat.ChatApi, lightUserApi: lila.user.LightUserApi -)(implicit ec: scala.concurrent.ExecutionContext) { +)( + implicit + ec: scala.concurrent.ExecutionContext, + system: akka.actor.ActorSystem, + // mat: akka.stream.Materializer, + idGenerator: lila.game.IdGenerator, + mode: play.api.Mode +) { private val colls = wire[SwissColls] val api = wire[SwissApi] - def version(tourId: Swiss.Id): Fu[SocketVersion] = - fuccess(SocketVersion(0)) - // socket.rooms.ask[SocketVersion](tourId)(GetVersion) + private lazy val socket = wire[SwissSocket] + + def version(swissId: Swiss.Id): Fu[SocketVersion] = + socket.rooms.ask[SocketVersion](swissId.value)(GetVersion) lazy val json = wire[SwissJson] diff --git a/modules/swiss/src/main/Swiss.scala b/modules/swiss/src/main/Swiss.scala index c0b9f4cce4..36762632a5 100644 --- a/modules/swiss/src/main/Swiss.scala +++ b/modules/swiss/src/main/Swiss.scala @@ -30,12 +30,12 @@ case class Swiss( ) { def id = _id - def isCreated = status == Status.Created - def isStarted = status == Status.Started - def isFinished = status == Status.Finished - def isEnterable = !isFinished - - def isNowOrSoon = startsAt.isBefore(DateTime.now plusMinutes 15) && !isFinished + def isCreated = status == Status.Created + def isStarted = status == Status.Started + def isFinished = status == Status.Finished + def isEnterable = !isFinished + def isNowOrSoon = startsAt.isBefore(DateTime.now plusMinutes 15) && !isFinished + def secondsToStart = (startsAt.getSeconds - nowSeconds).toInt atLeast 0 def allRounds: List[SwissRound.Number] = (1 to round.value).toList.map(SwissRound.Number.apply) def finishedRounds: List[SwissRound.Number] = (1 to (round.value - 1)).toList.map(SwissRound.Number.apply) diff --git a/modules/swiss/src/main/SwissApi.scala b/modules/swiss/src/main/SwissApi.scala index 2d4b5c8e72..eb0c761954 100644 --- a/modules/swiss/src/main/SwissApi.scala +++ b/modules/swiss/src/main/SwissApi.scala @@ -85,4 +85,9 @@ final class SwissApi( def featuredInTeam(teamId: TeamID): Fu[List[Swiss]] = colls.swiss.ext.find($doc("teamId" -> teamId)).sort($sort desc "startsAt").list[Swiss](5) + + private def insertPairing(pairing: SwissPairing) = + colls.pairing.insert.one { + pairingHandler.write(pairing) ++ $doc("d" -> DateTime.now) + }.void } diff --git a/modules/swiss/src/main/SwissJson.scala b/modules/swiss/src/main/SwissJson.scala index cb4de7c08c..de73973dbf 100644 --- a/modules/swiss/src/main/SwissJson.scala +++ b/modules/swiss/src/main/SwissJson.scala @@ -8,6 +8,8 @@ import scala.concurrent.duration._ import scala.concurrent.ExecutionContext import lila.common.{ GreatPlayer, LightUser, Uptime } +import lila.db.dsl._ +import lila.game.Game import lila.hub.LightTeam.TeamID import lila.quote.Quote.quoteWriter import lila.rating.PerfType @@ -15,55 +17,105 @@ import lila.socket.Socket.SocketVersion import lila.user.User final class SwissJson( + colls: SwissColls, lightUserApi: lila.user.LightUserApi )(implicit ec: ExecutionContext) { + import BsonHandlers._ + def apply( swiss: Swiss, leaderboard: List[LeaderboardPlayer], me: Option[User], socketVersion: Option[SocketVersion] - )(implicit lang: Lang): Fu[JsObject] = fuccess { - Json - .obj( - "id" -> swiss.id.value, - "createdBy" -> swiss.createdBy, - "startsAt" -> formatDate(swiss.startsAt), - "name" -> swiss.name, - "perf" -> swiss.perfType, - "clock" -> swiss.clock, - "variant" -> swiss.variant.key, - "nbRounds" -> swiss.nbRounds, - "nbPlayers" -> swiss.nbPlayers, - "leaderboard" -> leaderboard.map { l => - Json.obj( - "player" -> Json.obj( - "user" -> lightUserApi.sync(l.player.userId), - "rating" -> l.player.rating, - "points" -> l.player.points, - "score" -> l.player.score - ), - "pairings" -> swiss.allRounds.map(l.pairings.get).map { - _.fold[JsValue](JsNull) { p => - Json.obj( - "o" -> p.opponentOf(l.player.number), - "g" -> p.gameId, - "w" -> p.winner.map(l.player.number.==) + )(implicit lang: Lang): Fu[JsObject] = + me.?? { fetchMyInfo(swiss, _) } map { myInfo => + Json + .obj( + "id" -> swiss.id.value, + "createdBy" -> swiss.createdBy, + "startsAt" -> formatDate(swiss.startsAt), + "name" -> swiss.name, + "perf" -> swiss.perfType, + "clock" -> swiss.clock, + "variant" -> swiss.variant.key, + "round" -> swiss.round, + "nbRounds" -> swiss.nbRounds, + "nbPlayers" -> swiss.nbPlayers, + "leaderboard" -> leaderboard.map { l => + Json.obj( + "player" -> Json + .obj( + "user" -> lightUserApi.sync(l.player.userId), + "rating" -> l.player.rating, + "points" -> l.player.points, + "score" -> l.player.score ) + .add("provisional" -> l.player.provisional), + "pairings" -> swiss.allRounds.map(l.pairings.get).map { + _.fold[JsValue](JsNull) { p => + Json.obj( + "o" -> p.opponentOf(l.player.number), + "g" -> p.gameId, + "w" -> p.winner.map(l.player.number.==) + ) + } } - } + ) + } + ) + .add("isStarted" -> swiss.isStarted) + .add("isFinished" -> swiss.isFinished) + .add("socketVersion" -> socketVersion.map(_.value)) + .add("quote" -> swiss.isCreated.option(lila.quote.Quote.one(swiss.id.value))) + .add("description" -> swiss.description) + .add("secondsToStart" -> swiss.isCreated.option(swiss.secondsToStart)) + .add("me" -> myInfo.map(myInfoJson(me))) + } + + def fetchMyInfo(swiss: Swiss, me: User): Fu[Option[MyInfo]] = + colls.swiss.one[SwissPlayer]($doc("s" -> swiss.id, "u" -> me.id)) flatMap { + _ ?? { player => + colls.pairing + .find( + $doc("s" -> swiss.id, "u" -> me.id), + $doc("_id" -> true).some ) - } - ) - .add("isStarted" -> swiss.isStarted) - .add("isFinished" -> swiss.isFinished) - .add("socketVersion" -> socketVersion.map(_.value)) - .add("quote" -> swiss.isCreated.option(lila.quote.Quote.one(swiss.id.value))) - .add("description" -> swiss.description) - } + .sort($sort desc "d") + .one[Bdoc] + .dmap { _.flatMap(_.getAsOpt[Game.ID]("_id")) } + .flatMap { gameId => + getOrGuessRank(swiss, player) dmap { rank => + MyInfo(rank + 1, false, gameId).some + } + } + } + } + + // if the user is not yet in the cached ranking, + // guess its rank based on other players scores in the DB + private def getOrGuessRank(swiss: Swiss, player: SwissPlayer): Fu[Int] = ??? + // cached ranking swiss flatMap { + // _ get player.userId match { + // case Some(rank) => fuccess(rank) + // case None => playerRepo.computeRankOf(player) + // } + // } private def formatDate(date: DateTime) = ISODateTimeFormat.dateTime print date + private def myInfoJson(u: Option[User])(i: MyInfo) = + Json + .obj( + "rank" -> i.rank, + "withdraw" -> i.withdraw, + "gameId" -> i.gameId, + "username" -> u.map(_.titleUsername) + ) + + implicit private val roundNumberWriter: Writes[SwissRound.Number] = Writes[SwissRound.Number] { n => + JsNumber(n.value) + } implicit private val playerNumberWriter: Writes[SwissPlayer.Number] = Writes[SwissPlayer.Number] { n => JsNumber(n.value) } diff --git a/modules/swiss/src/main/model.scala b/modules/swiss/src/main/model.scala index 6d6ea2aa94..613d44f478 100644 --- a/modules/swiss/src/main/model.scala +++ b/modules/swiss/src/main/model.scala @@ -29,14 +29,14 @@ object SwissRound { } case class SwissPairing( - _id: SwissPairing.Id, // random + _id: Game.ID, swissId: Swiss.Id, round: SwissRound.Number, - gameId: Game.ID, white: SwissPlayer.Number, black: SwissPlayer.Number, winner: Option[SwissPlayer.Number] ) { + def gameId = _id def players = List(white, black) def has(number: SwissPlayer.Number) = white == number || black == number def colorOf(number: SwissPlayer.Number) = chess.Color(white == number) @@ -45,10 +45,6 @@ case class SwissPairing( object SwissPairing { - case class Id(value: String) extends AnyVal with StringValue - - def makeId = Id(scala.util.Random.alphanumeric take 8 mkString) - case class Pending( white: SwissPlayer.Number, black: SwissPlayer.Number @@ -64,3 +60,9 @@ case class LeaderboardPlayer( player: SwissPlayer, pairings: Map[SwissRound.Number, SwissPairing] ) + +case class MyInfo(rank: Int, withdraw: Boolean, gameId: Option[Game.ID]) { + def page = { + math.floor((rank - 1) / 10) + 1 + }.toInt +} diff --git a/modules/tournament/src/main/JsonView.scala b/modules/tournament/src/main/JsonView.scala index a4515de18e..42955cd2d0 100644 --- a/modules/tournament/src/main/JsonView.scala +++ b/modules/tournament/src/main/JsonView.scala @@ -53,7 +53,7 @@ final class JsonView( )(implicit lang: Lang): Fu[JsObject] = for { data <- cachableData get tour.id - myInfo <- me ?? { myInfo(tour, _) } + myInfo <- me ?? { fetchMyInfo(tour, _) } pauseDelay = me flatMap { u => pause.remainingDelay(u.id, tour) } @@ -142,7 +142,7 @@ final class JsonView( cachableData invalidate tour.id } - def myInfo(tour: Tournament, me: User): Fu[Option[MyInfo]] = + def fetchMyInfo(tour: Tournament, me: User): Fu[Option[MyInfo]] = playerRepo.find(tour.id, me.id) flatMap { _ ?? { player => fetchCurrentGameId(tour, me) flatMap { gameId => diff --git a/ui/swiss/gulpfile.js b/ui/swiss/gulpfile.js index ad042e1888..56d5b7e0b7 100644 --- a/ui/swiss/gulpfile.js +++ b/ui/swiss/gulpfile.js @@ -1,2 +1,2 @@ -require('@build/tsProject')('LichessTournament', 'lichess.tournament', __dirname); +require('@build/tsProject')('LichessSwiss', 'lichess.swiss', __dirname); require('@build/cssProject')(__dirname); diff --git a/ui/swiss/src/boot.ts b/ui/swiss/src/boot.ts index 7f95f27f25..01a7923da3 100644 --- a/ui/swiss/src/boot.ts +++ b/ui/swiss/src/boot.ts @@ -8,12 +8,11 @@ export default function(opts: SwissOpts): void { li.socket = li.StrongSocket( '/swiss/' + cfg.data.id, cfg.data.socketVersion, { receive: function(t, d) { - return tournament.socketReceive(t, d); + return swiss.socketReceive(t, d); } }); cfg.socketSend = lichess.socket.send; cfg.element = element; - cfg.$side = $('.tour__side').clone(); - cfg.$faq = $('.tour__faq').clone(); - tournament = LichessTournament.start(cfg); + cfg.$side = $('.swiss__side').clone(); + LichessSwiss.start(cfg); } diff --git a/ui/swiss/src/ctrl.ts b/ui/swiss/src/ctrl.ts new file mode 100644 index 0000000000..e5c0ded5e4 --- /dev/null +++ b/ui/swiss/src/ctrl.ts @@ -0,0 +1,50 @@ +import makeSocket from './socket'; +import xhr from './xhr'; +import { myPage, players } from './pagination'; +import * as sound from './sound'; +import * as tour from './tournament'; +import { TournamentData, TournamentOpts, Pages, PlayerInfo, TeamInfo, Standing } from './interfaces'; +import { TournamentSocket } from './socket'; + +interface CtrlTeamInfo { + requested?: string; + loaded?: TeamInfo; +} + +export default class TournamentController { + + opts: TournamentOpts; + data: TournamentData; + trans: Trans; + socket: TournamentSocket; + page: number; + pages: Pages = {}; + lastPageDisplayed: number | undefined; + focusOnMe: boolean; + joinSpinner: boolean = false; + playerInfo: PlayerInfo = {}; + teamInfo: CtrlTeamInfo = {}; + disableClicks: boolean = true; + searching: boolean = false; + joinWithTeamSelector: boolean = false; + redraw: () => void; + + private watchingGameId: string; + private lastStorage = window.lichess.storage.make('last-redirect'); + + constructor(opts: TournamentOpts, redraw: () => void) { + this.opts = opts; + this.data = opts.data; + this.redraw = redraw; + this.trans = window.lichess.trans(opts.i18n); + this.socket = makeSocket(opts.socketSend, this); + this.page = this.data.standing.page; + this.focusOnMe = tour.isIn(this); + setTimeout(() => this.disableClicks = false, 1500); + this.loadPage(this.data.standing); + this.scrollToMe(); + sound.end(this.data); + sound.countDown(this.data); + this.redirectToMyGame(); + if (this.data.featured) this.startWatching(this.data.featured.id); + } diff --git a/ui/swiss/src/interfaces.ts b/ui/swiss/src/interfaces.ts index 9ce5e9fe22..f3359fa4e3 100644 --- a/ui/swiss/src/interfaces.ts +++ b/ui/swiss/src/interfaces.ts @@ -3,90 +3,58 @@ import { VNode } from 'snabbdom/vnode' export type MaybeVNode = VNode | string | null | undefined; export type MaybeVNodes = MaybeVNode[]; -interface Untyped { - [key: string]: any; -} - -export interface StandingPlayer extends Untyped { -} - -export interface Standing { - failed?: boolean; - page: number; - players: StandingPlayer[]; -} - -export interface SwissOpts extends Untyped { +export interface SwissOpts { + data: SwissData; + userId?: string; element: HTMLElement; socketSend: SocketSend; + chat: any; } -export interface SwissData extends Untyped { - teamBattle?: TeamBattle; - teamStanding?: RankedTeam[]; -} - -export interface TeamBattle { - teams: { - [id: string]: string - }; - joinWith: string[]; -} - -export interface RankedTeam { +export interface SwissData { id: string; - rank: number; - score: number; - players: TeamPlayer[]; -} - -export interface TeamPlayer { - user: { - name: string - }; - score: number -} - -export type Page = StandingPlayer[]; - -export interface Pages { - [n: number]: Page -} - -export interface PlayerInfo { - id?: string; - player?: any; - data?: any; -} -export interface TeamInfo { - id: string; - nbPlayers: number; - rating: number; - perf: number; - score: number; - topPlayers: TeamPlayer[]; -} - -export interface TeamPlayer { + createdBy: number; + startsAt: string; name: string; + perf: PerfType; + clock: Clock; + variant: string; + round: number; + nbRounds: number; + nbPlayers: number; + leaderboard: [LeaderboardLine]; + isStarted?: boolean; + isFinished?: boolean; + socketVersion?: number; + quote?: string; + description?: string; +} + +export interface Pairing { + o: number; + g: string; + w?: boolean; +} + +export interface LeaderboardLine { + player: LeaderboardPlayer; + pairings: [Pairing | null]; +} + +export interface LeaderboardPlayer { + user: LightUser; rating: number; + provisional?: boolean; + points: number; score: number; - fire: boolean; - title?: string; } -export interface Duel { - id: string; - p: [DuelPlayer, DuelPlayer] +export interface PerfType { + icon: string; + name: string; } -export interface DuelPlayer { - n: string; // name - r: number // rating - k: number // rank - t?: string // title -} - -export interface DuelTeams { - [userId: string]: string +export interface Clock { + limit: number; + increment: number; } diff --git a/ui/swiss/src/main.ts b/ui/swiss/src/main.ts index a52f6d7d3f..632705e88d 100644 --- a/ui/swiss/src/main.ts +++ b/ui/swiss/src/main.ts @@ -18,8 +18,8 @@ export function start(opts: SwissOpts) { let vnode: VNode, ctrl: SwissController; - function redraw() { - vnode = patch(vnode, view(ctrl)); + const redraw: Redraw = () => { + vnode = patch(vnode || element, ctrl ? loaded(ctrl) : loading()); } ctrl = new makeCtrl(opts, redraw); diff --git a/ui/swiss/src/socket.ts b/ui/swiss/src/socket.ts new file mode 100644 index 0000000000..9c394745ae --- /dev/null +++ b/ui/swiss/src/socket.ts @@ -0,0 +1,27 @@ +import SwissController from './ctrl'; + +export interface SwissSocket { + send: SocketSend; + receive(type: string, data: any): void; +} + +export default function(send: SocketSend, ctrl: SwissController) { + + const handlers = { + reload() { + setTimeout(ctrl.askReload, Math.floor(Math.random() * 4000)) + }, + redirect(fullId) { + ctrl.redirectFirst(fullId.slice(0, 8), true); + return true; + } + }; + + return { + send, + receive(type: string, data: any) { + if (handlers[type]) return handlers[type](data); + return false; + } + }; +}; diff --git a/ui/swiss/src/xhr.ts b/ui/swiss/src/xhr.ts new file mode 100644 index 0000000000..5b47cb65ca --- /dev/null +++ b/ui/swiss/src/xhr.ts @@ -0,0 +1,81 @@ +import throttle from 'common/throttle'; +import SwissController from './ctrl'; + +const headers = { + 'Accept': 'application/vnd.lichess.v5+json' +}; + +// when the tournament no longer exists +function onFail(_1, _2, errorMessage) { + if (errorMessage === 'Forbidden') location.href = '/'; + else window.lichess.reload(); +} + +function join(ctrl: SwissController) { + return $.ajax({ + method: 'POST', + url: '/swiss/' + ctrl.data.id + '/join', + contentType: 'application/json; charset=utf-8', + headers + }).fail(onFail); +} + +function withdraw(ctrl: SwissController) { + return $.ajax({ + method: 'POST', + url: '/swiss/' + ctrl.data.id + '/withdraw', + headers + }).fail(onFail); +} + +function loadPage(ctrl: SwissController, p: number) { + $.ajax({ + url: '/swiss/' + ctrl.data.id + '/standing/' + p, + headers + }).then(data => { + ctrl.loadPage(data); + ctrl.redraw(); + }, onFail); +} + +function loadPageOf(ctrl: SwissController, userId: string): JQueryXHR { + return $.ajax({ + url: '/swiss/' + ctrl.data.id + '/page-of/' + userId, + headers + }); +} + +function reload(ctrl: SwissController) { + return $.ajax({ + url: '/swiss/' + ctrl.data.id, + data: { + page: ctrl.focusOnMe ? null : ctrl.page, + playerInfo: ctrl.playerInfo.id, + partial: true + }, + headers + }).then(data => { + ctrl.reload(data); + ctrl.redraw(); + }, onFail); +} + +function playerInfo(ctrl: SwissController, userId: string) { + return $.ajax({ + url: ['/swiss', ctrl.data.id, 'player', userId].join('/'), + headers + }).then(data => { + ctrl.setPlayerInfoData(data); + ctrl.redraw(); + }, onFail); +} + +export default { + join: throttle(1000, join), + withdraw: throttle(1000, withdraw), + loadPage: throttle(1000, loadPage), + loadPageOf, + reloadSoon: throttle(4000, reload), + reloadNow: reload, + playerInfo +};