swiss WIP

swiss
Thibault Duplessis 2020-04-29 18:00:47 -06:00
parent 39b0394bec
commit 80c8355ab2
15 changed files with 330 additions and 137 deletions

View File

@ -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})

View File

@ -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 {

View File

@ -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]

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 =>

View File

@ -1,2 +1,2 @@
require('@build/tsProject')('LichessTournament', 'lichess.tournament', __dirname);
require('@build/tsProject')('LichessSwiss', 'lichess.swiss', __dirname);
require('@build/cssProject')(__dirname);

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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;
}
};
};

View File

@ -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
};