From f09a2e9893b4d8e3f8e24cfd861780243f50e96b Mon Sep 17 00:00:00 2001 From: Philippe Suter Date: Fri, 4 Jul 2014 11:01:06 -0400 Subject: [PATCH] Tournament systems. - Introduced the concept of tournament systems: a system is a combination of pairing and a scoring system. - Tournaments now store "events" in addition to the pairings. Events are an extensible meta-information mechanism. - Factored out current hardcoded tournament logic into the "Arena" system. Arena is the default system. - Added a "Swiss" system for FIDE-like tournaments. Pairing logic is based on the FIDE-sanctionned Burstein system. - The Swiss system pairs players as soon as all games in the previous round are completed. Players get paired regardless of whether they are present in the lobby. --- .gitmodules | 3 + app/templating/SetupHelper.scala | 6 + app/templating/TournamentHelper.scala | 9 +- app/views/game/infoBox.scala.html | 14 +- ...ng.scala.html => arenaStanding.scala.html} | 4 +- app/views/tournament/form.scala.html | 4 + app/views/tournament/infoBox.scala.html | 2 + .../tournament/show/finishedInner.scala.html | 10 +- .../tournament/show/startedInner.scala.html | 9 +- app/views/tournament/swissStanding.scala.html | 52 +++++ conf/messages | 3 + modules/i18n/src/main/I18nKeys.scala | 3 + modules/setup/src/main/Config.scala | 3 + modules/setup/src/main/Mappings.scala | 1 + modules/swisssystem | 1 + modules/tournament/src/main/DataForm.scala | 3 + modules/tournament/src/main/Event.scala | 47 ++++ modules/tournament/src/main/Organizer.scala | 8 +- modules/tournament/src/main/Pairing.scala | 95 ++------ modules/tournament/src/main/Player.scala | 2 +- modules/tournament/src/main/System.scala | 60 ++++++ modules/tournament/src/main/Tournament.scala | 72 +++++-- .../tournament/src/main/TournamentApi.scala | 7 +- .../src/main/arena/PairingSystem.scala | 81 +++++++ .../ScoringSystem.scala} | 50 +++-- modules/tournament/src/main/package.scala | 4 +- .../src/main/swiss/SwissSystem.scala | 204 ++++++++++++++++++ project/Build.scala | 9 +- 28 files changed, 637 insertions(+), 129 deletions(-) rename app/views/tournament/{standing.scala.html => arenaStanding.scala.html} (94%) create mode 100644 app/views/tournament/swissStanding.scala.html create mode 160000 modules/swisssystem create mode 100644 modules/tournament/src/main/Event.scala create mode 100644 modules/tournament/src/main/System.scala create mode 100644 modules/tournament/src/main/arena/PairingSystem.scala rename modules/tournament/src/main/{Score.scala => arena/ScoringSystem.scala} (53%) create mode 100644 modules/tournament/src/main/swiss/SwissSystem.scala diff --git a/.gitmodules b/.gitmodules index e26d3b398e..1324b5d610 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "public/vendor/tagmanager"] path = public/vendor/tagmanager url = https://github.com/max-favilli/tagmanager +[submodule "modules/swisssystem"] + path = modules/swisssystem + url = https://github.com/psuter/swisssystem diff --git a/app/templating/SetupHelper.scala b/app/templating/SetupHelper.scala index 283a56fb8e..10e6cca039 100644 --- a/app/templating/SetupHelper.scala +++ b/app/templating/SetupHelper.scala @@ -4,6 +4,7 @@ package templating import chess.{ Mode, Variant, Speed } import lila.setup._ import lila.api.Context +import lila.tournament.System trait SetupHelper { self: I18nHelper => @@ -12,6 +13,11 @@ trait SetupHelper { self: I18nHelper => Mode.Rated.id.toString -> trans.rated.str() ) + def translatedSystemChoices(implicit ctx: Context) = List( + System.Arena.id.toString -> trans.arena.str(), + System.Swiss.id.toString -> trans.swiss.str() + ) + def translatedVariantChoices(implicit ctx: Context) = List( Variant.Standard.id.toString -> trans.standard.str(), Variant.Chess960.id.toString -> Variant.Chess960.name.capitalize diff --git a/app/templating/TournamentHelper.scala b/app/templating/TournamentHelper.scala index 09b6f472e1..42e11447c5 100644 --- a/app/templating/TournamentHelper.scala +++ b/app/templating/TournamentHelper.scala @@ -3,8 +3,8 @@ package templating import controllers.routes import lila.api.Context -import lila.tournament.Tournament -import lila.user.User +import lila.tournament.{ Tournament, System } +import lila.user.{ User, UserContext } import play.api.libs.json.Json import play.twirl.api.Html @@ -28,4 +28,9 @@ trait TournamentHelper { self: I18nHelper => val name = if (tour.scheduled) tour.name else trans.xTournament(tour.name) s""" $name""" } + + def systemName(sys: System)(implicit ctx: UserContext) = sys match { + case System.Arena => trans.arena.str() + case System.Swiss => trans.swiss.str() + } } diff --git a/app/views/game/infoBox.scala.html b/app/views/game/infoBox.scala.html index 03e6fcf2b0..6303066c69 100644 --- a/app/views/game/infoBox.scala.html +++ b/app/views/game/infoBox.scala.html @@ -1,6 +1,7 @@ @(pov: Pov, tour: Option[lila.tournament.Tournament], withTourStanding: Boolean)(implicit ctx: Context) @import pov._ +@import lila.tournament.arena
@@ -69,10 +70,13 @@ @t.rankedPlayers.map { case (rank, player) => { @defining(( - if(t.isFinished && rank == 1) "winner" else if (player.withdraw) "withdraw" else "", - t scoreSheet player - )) { - case (flag, scoreSheet) => { + t scoreSheet player, + // TODO FIXME that's a little ugly I must say. + t.system.scoringSystem match { + case ss @ arena.ScoringSystem => ss.scoreSheet(t, player.id).onFire + case _ => false + })) { + case (scoreSheet, onFire) => { @if(player.withdraw) { @@ -87,7 +91,7 @@ @userInfosLink(player.id, none, withOnline = false) - @scoreSheet.total + @scoreSheet.total
diff --git a/app/views/tournament/standing.scala.html b/app/views/tournament/arenaStanding.scala.html similarity index 94% rename from app/views/tournament/standing.scala.html rename to app/views/tournament/arenaStanding.scala.html index b14ec56763..f7d445ed1c 100644 --- a/app/views/tournament/standing.scala.html +++ b/app/views/tournament/arenaStanding.scala.html @@ -1,5 +1,7 @@ @(tour: lila.tournament.StartedOrFinished)(implicit ctx: Context) +@import lila.tournament.arena.ScoringSystem +
@@ -17,7 +19,7 @@ case (rank, player) => { @defining(( if(tour.isFinished && rank == 1) "winner" else if (player.withdraw) "withdraw" else "", - tour scoreSheet player + ScoringSystem.scoreSheet(tour, player.id) )) { case (flag, scoreSheet) => { diff --git a/app/views/tournament/form.scala.html b/app/views/tournament/form.scala.html index b20d8d4e31..2a97d668bd 100644 --- a/app/views/tournament/form.scala.html +++ b/app/views/tournament/form.scala.html @@ -31,6 +31,10 @@ moreJs = moreJs) {

@error.message

}
+ + + + diff --git a/app/views/tournament/infoBox.scala.html b/app/views/tournament/infoBox.scala.html index 531c2d513c..f93f0a69f7 100644 --- a/app/views/tournament/infoBox.scala.html +++ b/app/views/tournament/infoBox.scala.html @@ -24,6 +24,8 @@ @variantName(tour.variant).capitalize, @{ tour.rated.fold(trans.rated(), trans.casual()) }

+ @trans.system(): @systemName(tour.system).capitalize +

@trans.duration(): @tour.minutes minutes @if(tour.isRunning) {

diff --git a/app/views/tournament/show/finishedInner.scala.html b/app/views/tournament/show/finishedInner.scala.html index 745dd0033b..6577ae8e8c 100644 --- a/app/views/tournament/show/finishedInner.scala.html +++ b/app/views/tournament/show/finishedInner.scala.html @@ -4,6 +4,14 @@

 @if(tour.scheduled){@tour.name} else {@trans.xTournament(tour.name)}

-@tournament.standing(tour) +@tour.system match { + case lila.tournament.System.Arena => { + @tournament.arenaStanding(tour) + } + + case lila.tournament.System.Swiss => { + @tournament.swissStanding(tour) + } +} @tournament.games(games) diff --git a/app/views/tournament/show/startedInner.scala.html b/app/views/tournament/show/startedInner.scala.html index c7dc55aa90..a63977024a 100644 --- a/app/views/tournament/show/startedInner.scala.html +++ b/app/views/tournament/show/startedInner.scala.html @@ -13,6 +13,13 @@ } -@tournament.standing(tour) +@tour.system match { + case lila.tournament.System.Arena => { + @tournament.arenaStanding(tour) + } + case lila.tournament.System.Swiss => { + @tournament.swissStanding(tour) + } +} @tournament.games(games) diff --git a/app/views/tournament/swissStanding.scala.html b/app/views/tournament/swissStanding.scala.html new file mode 100644 index 0000000000..bc6adb14a8 --- /dev/null +++ b/app/views/tournament/swissStanding.scala.html @@ -0,0 +1,52 @@ +@(tour: lila.tournament.StartedOrFinished)(implicit ctx: Context) + +@import lila.tournament.swiss.SwissSystem + +
+
@base.select(form("system"), translatedSystemChoices)
@base.select(form("variant"), translatedVariantChoices)
+ + + + + + + + + @defining(SwissSystem.scoreSheets(tour)) { + case scoreSheets => { + @tour.rankedPlayers.map { + case (rank, player) => { + @defining(scoreSheets(player.id)) { + case scoreSheet => { + + + + + + + } + } + } + } + } + } + +
@trans.standing() (@tour.nbPlayers)
+ @if(player.withdraw) { + + } else { + @if(tour.isFinished && rank == 1) { + + } else { + @rank + } + } + @userInfosLink(player.id, none, withOnline = false) + + @scoreSheet.scores.reverse.map { score => + @score.repr + } + + @scoreSheet.totalRepr (@scoreSheet.neustadtlRepr) +
+
diff --git a/conf/messages b/conf/messages index 8529133af0..c678217905 100644 --- a/conf/messages +++ b/conf/messages @@ -111,6 +111,9 @@ unlimited=Unlimited mode=Mode casual=Casual rated=Rated +system=System +arena=Arena +swiss=Swiss thisGameIsRated=This game is rated rematch=Rematch rematchOfferSent=Rematch offer sent diff --git a/modules/i18n/src/main/I18nKeys.scala b/modules/i18n/src/main/I18nKeys.scala index 115e8c9eca..f67ae2c3d7 100644 --- a/modules/i18n/src/main/I18nKeys.scala +++ b/modules/i18n/src/main/I18nKeys.scala @@ -135,6 +135,9 @@ final class I18nKeys(translator: Translator) { val `mode` = new Key("mode") val `casual` = new Key("casual") val `rated` = new Key("rated") + val `system` = new Key("system") + val `arena` = new Key("arena") + val `swiss` = new Key("swiss") val `thisGameIsRated` = new Key("thisGameIsRated") val `rematch` = new Key("rematch") val `rematchOfferSent` = new Key("rematchOfferSent") diff --git a/modules/setup/src/main/Config.scala b/modules/setup/src/main/Config.scala index d638c33d94..928ceef1b8 100644 --- a/modules/setup/src/main/Config.scala +++ b/modules/setup/src/main/Config.scala @@ -5,6 +5,7 @@ import chess.{ Game => ChessGame, Board, Situation, Variant, Clock, Speed } import lila.game.{ GameRepo, Game, Pov } import lila.lobby.Color +import lila.tournament.{ System => TournamentSystem } private[setup] trait Config { @@ -81,6 +82,8 @@ trait Positional { self: Config => object Config extends BaseConfig trait BaseConfig { + val systems = List(TournamentSystem.Arena.id, TournamentSystem.Swiss.id) + val systemDefault = TournamentSystem.default val variants = List(Variant.Standard.id, Variant.Chess960.id) val variantDefault = Variant.Standard diff --git a/modules/setup/src/main/Mappings.scala b/modules/setup/src/main/Mappings.scala index 67199a80b3..d808a16af4 100644 --- a/modules/setup/src/main/Mappings.scala +++ b/modules/setup/src/main/Mappings.scala @@ -22,4 +22,5 @@ object Mappings { val level = number.verifying(AiConfig.levels contains _) val speed = number.verifying(Config.speeds contains _) val fen = optional(nonEmptyText) + val system = number.verifying(Config.systems contains _) } diff --git a/modules/swisssystem b/modules/swisssystem new file mode 160000 index 0000000000..a416874795 --- /dev/null +++ b/modules/swisssystem @@ -0,0 +1 @@ +Subproject commit a4168747954cb70f4bb34a45af0b72c5ccad9a9c diff --git a/modules/tournament/src/main/DataForm.scala b/modules/tournament/src/main/DataForm.scala index 1d7876e562..191a215778 100644 --- a/modules/tournament/src/main/DataForm.scala +++ b/modules/tournament/src/main/DataForm.scala @@ -35,6 +35,7 @@ final class DataForm(isDev: Boolean) { "clockIncrement" -> numberIn(clockIncrementChoices), "minutes" -> numberIn(minuteChoices), "minPlayers" -> numberIn(minPlayerChoices), + "system" -> number.verifying(Set(System.Arena.id, System.Swiss.id) contains _), "variant" -> number.verifying(Set(Variant.Standard.id, Variant.Chess960.id) contains _), "mode" -> number.verifying(Mode.all map (_.id) contains _), "password" -> optional(nonEmptyText) @@ -46,6 +47,7 @@ final class DataForm(isDev: Boolean) { clockIncrement = clockIncrementDefault, minutes = minuteDefault, minPlayers = minPlayerDefault, + system = System.default.id, variant = Variant.Standard.id, password = none, mode = Mode.Casual.id) @@ -60,6 +62,7 @@ private[tournament] case class TournamentSetup( clockIncrement: Int, minutes: Int, minPlayers: Int, + system: Int, variant: Int, mode: Int, password: Option[String]) { diff --git a/modules/tournament/src/main/Event.scala b/modules/tournament/src/main/Event.scala new file mode 100644 index 0000000000..7961bbe1a3 --- /dev/null +++ b/modules/tournament/src/main/Event.scala @@ -0,0 +1,47 @@ +package lila.tournament + +import org.joda.time.DateTime + +// Metadata about running tournaments: who got byed, when a round completed, this sort of things. +sealed abstract class Event(val id: Int) { + def timestamp: DateTime + def encode: RawEvent +} + +case class RoundEnd(timestamp: DateTime) extends Event(1) { + def encode = RawEvent(id, timestamp, None) +} + +case class Bye(user: String, timestamp: DateTime) extends Event(10) { + def encode = RawEvent(id, timestamp, Some(user)) +} + +private[tournament] case class RawEvent( + i: Int, + t: DateTime, + u: Option[String]) { + + def decode: Option[Event] = roundEnd orElse bye + + def roundEnd: Option[RoundEnd] = (i == 1) option RoundEnd(t) + + def bye: Option[Bye] = for { + usr <- u + if i == 10 + } yield Bye(usr, t) +} + +private[tournament] object RawEvent { + import lila.db.JsTube + import JsTube.Helpers._ + import play.api.libs.json._ + + private def defaults = Json.obj( + "u" -> none[String] + ) + + private[tournament] val tube = JsTube( + (__.json update merge(defaults)) andThen Json.reads[RawEvent], + Json.writes[RawEvent] + ) +} diff --git a/modules/tournament/src/main/Organizer.scala b/modules/tournament/src/main/Organizer.scala index c74d0756a9..b4c6d40929 100644 --- a/modules/tournament/src/main/Organizer.scala +++ b/modules/tournament/src/main/Organizer.scala @@ -60,8 +60,12 @@ private[tournament] final class Organizer( if (!tour.isAlmostFinished) { withUserIds(tour.id) { ids => (tour.activeUserIds intersect ids) |> { users => - Pairing.createNewPairings(users, tour.pairings, tour.nbActiveUsers).toNel foreach { pairings => - api.makePairings(tour, pairings) + + tour.system.pairingSystem.createPairings(tour, users) onSuccess { + case (pairings, events) => + pairings.toNel foreach { pairings => + api.makePairings(tour, pairings, events) + } } } } diff --git a/modules/tournament/src/main/Pairing.scala b/modules/tournament/src/main/Pairing.scala index b6a24f2025..2aac9606e7 100644 --- a/modules/tournament/src/main/Pairing.scala +++ b/modules/tournament/src/main/Pairing.scala @@ -1,19 +1,20 @@ package lila.tournament -import scala.util.Random - import chess.Color import lila.game.{ PovRef, IdGenerator } +import org.joda.time.DateTime + case class Pairing( gameId: String, status: chess.Status, user1: String, user2: String, winner: Option[String], - turns: Option[Int]) { + turns: Option[Int], + pairedAt: Option[DateTime]) { - def encode: RawPairing = RawPairing(gameId, status.id, users, winner, turns) + def encode: RawPairing = RawPairing(gameId, status.id, users, winner, turns, pairedAt) def users = List(user1, user2) def usersPair = user1 -> user2 @@ -51,90 +52,29 @@ case class Pairing( } private[tournament] object Pairing { - type P = (String, String) - def apply(users: P): Pairing = apply(users._1, users._2) - def apply(user1: String, user2: String): Pairing = new Pairing( + def apply(us: P): Pairing = apply(us._1, us._2) + def apply(u1: String, u2: String): Pairing = apply(u1, u2, None) + def apply(u1: String, u2: String, pa: DateTime): Pairing = apply(u1, u2, Some(pa)) + + def apply(u1: String, u2: String, pa: Option[DateTime]): Pairing = new Pairing( gameId = IdGenerator.game, status = chess.Status.Created, - user1 = user1, - user2 = user2, + user1 = u1, + user2 = u2, winner = none, - turns = none) - - def createNewPairings(users: List[String], pairings: Pairings, nbActiveUsers: Int): Pairings = - - if (users.size < 2) - Nil - else { - val idles: List[String] = Random shuffle { - users.toSet diff { (pairings filter (_.playing) flatMap (_.users)).toSet } toList - } - - pairings.isEmpty.fold( - naivePairings(idles), - (idles.size > 12).fold( - naivePairings(idles), - smartPairings(idles, pairings, nbActiveUsers) - ) - ) - } - - private def naivePairings(users: List[String]) = - Random shuffle users grouped 2 collect { - case List(u1, u2) => Pairing(u1, u2) - } toList - - private def smartPairings(users: List[String], pairings: Pairings, nbActiveUsers: Int): Pairings = { - - def lastOpponent(user: String): Option[String] = - pairings find (_ contains user) flatMap (_ opponentOf user) - - def justPlayedTogether(u1: String, u2: String): Boolean = - lastOpponent(u1) == u2.some && lastOpponent(u2) == u1.some - - def timeSincePlay(u: String): Int = - pairings.takeWhile(_ notContains u).size - - // lower is better - def score(pair: P): Int = pair match { - case (a, b) => justPlayedTogether(a, b).fold( - 100, - -timeSincePlay(a) - timeSincePlay(b)) - } - - (users match { - case x if x.size < 2 => Nil - case List(u1, u2) if nbActiveUsers == 2 => List(u1 -> u2) - case List(u1, u2) if justPlayedTogether(u1, u2) => Nil - case List(u1, u2) => List(u1 -> u2) - case us => allPairCombinations(us) - .map(c => c -> c.map(score).sum) - .sortBy(_._2) - .headOption - .map(_._1) | Nil - }) map Pairing.apply - } - - def allPairCombinations(list: List[String]): List[List[(String, String)]] = list match { - case a :: rest => for { - b ← rest - init = (a -> b) - nps = allPairCombinations(rest filter (b !=)) - ps ← nps.isEmpty.fold(List(List(init)), nps map (np => init :: np)) - } yield ps - case _ => Nil - } + turns = none, + pairedAt = pa) } -private[tournament] case class RawPairing(g: String, s: Int, u: List[String], w: Option[String], t: Option[Int]) { +private[tournament] case class RawPairing(g: String, s: Int, u: List[String], w: Option[String], t: Option[Int], p: Option[DateTime]) { def decode: Option[Pairing] = for { status ← chess.Status(s) user1 ← u.lift(0) user2 ← u.lift(1) - } yield Pairing(g, status, user1, user2, w, t) + } yield Pairing(g, status, user1, user2, w, t, p) } private[tournament] object RawPairing { @@ -145,7 +85,8 @@ private[tournament] object RawPairing { private def defaults = Json.obj( "w" -> none[String], - "t" -> none[Int]) + "t" -> none[Int], + "p" -> none[DateTime]) private[tournament] val tube = JsTube( (__.json update merge(defaults)) andThen Json.reads[RawPairing], diff --git a/modules/tournament/src/main/Player.scala b/modules/tournament/src/main/Player.scala index dfa856c762..00c39b8232 100644 --- a/modules/tournament/src/main/Player.scala +++ b/modules/tournament/src/main/Player.scala @@ -25,7 +25,7 @@ private[tournament] object Player { rating = user.rating) private[tournament] def refresh(tour: Tournament): Players = tour.players map { p => - p.copy(score = Score.sheet(p.id, tour).total) + p.copy(score = tour.system.scoringSystem.scoreSheet(tour, p.id).total) } sortBy { p => p.withdraw.fold(Int.MaxValue, 0) - p.score } diff --git a/modules/tournament/src/main/System.scala b/modules/tournament/src/main/System.scala new file mode 100644 index 0000000000..5b316569b6 --- /dev/null +++ b/modules/tournament/src/main/System.scala @@ -0,0 +1,60 @@ +package lila.tournament + +import scala.concurrent.Future + +sealed abstract class System(val id: Int) { + val pairingSystem: PairingSystem + val scoringSystem: ScoringSystem +} + +object System { + case object Arena extends System(id = 1) { + val pairingSystem = arena.PairingSystem + val scoringSystem = arena.ScoringSystem + } + + case object Swiss extends System(id = 2) { + val pairingSystem = swiss.SwissSystem + val scoringSystem = swiss.SwissSystem + } + + val default = Arena + + val all = List(Arena, Swiss) + + val byId = all map { s => (s.id -> s) } toMap + + def apply(id: Int): Option[System] = byId get id + def orDefault(id: Int): System = apply(id) getOrElse default +} + +trait PairingSystem { + def createPairings(tournament: Tournament, users: List[String]): Future[(Pairings,Events)] +} + +trait Score { + val value: Int +} + +trait ScoreSheet { + def scores: List[Score] + def total: Int +} + +trait ScoringSystem { + type Sheet <: ScoreSheet + type RankedPlayers = List[(Int, Player)] + + // This should rank players by score, and rank all withdrawn players after active ones. + def rank(tournament: Tournament, players: Players): RankedPlayers + + // You must override either this one or scoreSheet! + def scoreSheets(tournament: Tournament): Map[String,Sheet] = { + tournament.players.map { p => + (p.id -> scoreSheet(tournament, p.id)) + } toMap + } + + // You must override either this one or scoreSheets! + def scoreSheet(tournament: Tournament, player: String): Sheet = scoreSheets(tournament)(player) +} diff --git a/modules/tournament/src/main/Tournament.scala b/modules/tournament/src/main/Tournament.scala index 9288e78ee2..737c303c0f 100644 --- a/modules/tournament/src/main/Tournament.scala +++ b/modules/tournament/src/main/Tournament.scala @@ -8,8 +8,10 @@ import chess.{ Variant, Mode } import lila.game.PovRef import lila.user.User + private[tournament] case class Data( name: String, + system: System, clock: TournamentClock, minutes: Int, minPlayers: Int, @@ -28,6 +30,7 @@ sealed trait Tournament { def players: Players def winner: Option[Player] def pairings: List[Pairing] + def events: List[Event] def isOpen: Boolean = false def isRunning: Boolean = false def isFinished: Boolean = false @@ -38,6 +41,7 @@ sealed trait Tournament { def minutes = data.minutes lazy val duration = new Duration(minutes * 60 * 1000) + def system = data.system def variant = data.variant def mode = data.mode def rated = mode.rated @@ -72,7 +76,11 @@ sealed trait Tournament { def userPairings(user: String) = pairings filter (_ contains user) - def scoreSheet(player: Player) = Score.sheet(player.id, this) + def scoreSheet(player: Player) = system.scoringSystem.scoreSheet(this, player.id) + + // Oldest first! + def pairingsAndEvents: List[Either[Pairing,Event]] = + (pairings.reverse.map(Left(_)) ::: events.map(Right(_))).sorted(Tournament.PairingEventOrdering) } sealed trait Enterable extends Tournament { @@ -100,16 +108,13 @@ sealed trait Enterable extends Tournament { } sealed trait StartedOrFinished extends Tournament { + type RankedPlayers = List[(Int,Player)] def startedAt: DateTime def withPlayers(s: Players): StartedOrFinished def refreshPlayers: StartedOrFinished - type RankedPlayers = List[(Int, Player)] - def rankedPlayers: RankedPlayers = players.foldLeft(Nil: RankedPlayers) { - case (Nil, p) => (1, p) :: Nil - case (list@((r0, p0) :: _), p) => ((p0.score == p.score).fold(r0, list.size + 1), p) :: list - }.reverse + def rankedPlayers: RankedPlayers = system.scoringSystem.rank(this, players) def winner = players.headOption def winnerUserId = winner map (_.id) @@ -121,6 +126,7 @@ sealed trait StartedOrFinished extends Tournament { id = id, status = status.id, name = data.name, + system = data.system.id, clock = data.clock, minutes = data.minutes, minPlayers = data.minPlayers, @@ -132,7 +138,8 @@ sealed trait StartedOrFinished extends Tournament { startedAt = startedAt.some, schedule = data.schedule, players = players, - pairings = pairings map (_.encode)) + pairings = pairings map (_.encode), + events = events map (_.encode)) def finishedAt = startedAt + duration } @@ -154,12 +161,15 @@ case class Created( def pairings = Nil + def events = Nil + def winner = None def encode = new RawTournament( id = id, status = Status.Created.id, name = data.name, + system = data.system.id, clock = data.clock, variant = data.variant.id, mode = data.mode.id, @@ -180,7 +190,7 @@ case class Created( def startIfReady = enoughPlayersToStart option start - def start = Started(id, data, DateTime.now, players, Nil) + def start = Started(id, data, DateTime.now, players, Nil, Nil) def asScheduled = schedule map { Scheduled(this, _) } @@ -201,7 +211,8 @@ case class Started( data: Data, startedAt: DateTime, players: Players, - pairings: List[Pairing]) extends StartedOrFinished with Enterable { + pairings: List[Pairing], + events: List[Event]) extends StartedOrFinished with Enterable { override def isRunning = true @@ -212,6 +223,9 @@ case class Started( pairings = pairings map { p => (p.gameId == gameId).fold(f(p), p) } ) + def addEvents(es: Events) = + copy(events = es ::: events) + def readyToFinish = (remainingSeconds == 0) || (nbActiveUsers < 2) def remainingSeconds: Float = math.max(0f, @@ -240,7 +254,8 @@ case class Started( data = tour.data, startedAt = tour.startedAt, players = tour.players, - pairings = tour.pairings filterNot (_.playing)) + pairings = tour.pairings filterNot (_.playing), + events = tour.events) } def withdraw(userId: String): Valid[Started] = contains(userId).fold( @@ -277,7 +292,8 @@ case class Finished( data: Data, startedAt: DateTime, players: Players, - pairings: List[Pairing]) extends StartedOrFinished { + pairings: List[Pairing], + events: List[Event]) extends StartedOrFinished { override def isFinished = true @@ -324,12 +340,14 @@ object Tournament { clock: TournamentClock, minutes: Int, minPlayers: Int, + system: System, variant: Variant, mode: Mode, password: Option[String]): Created = Created( id = Random nextStringUppercase 8, data = Data( name = RandomName(), + system = system, clock = clock, createdBy = createdBy.id, createdAt = DateTime.now, @@ -345,6 +363,7 @@ object Tournament { id = Random nextStringUppercase 8, data = Data( name = sched.name, + system = System.default, clock = Schedule clockFor sched, createdBy = "lichess", createdAt = DateTime.now, @@ -355,11 +374,29 @@ object Tournament { schedule = Some(sched), minPlayers = 0), players = List()) + + // To sort combined sequences of pairings and events. + // This sorts all pairings/events in chronological order. Pairings without a timestamp + // are assumed to have happened at the origin of time (i.e. before anything else). + object PairingEventOrdering extends Ordering[Either[Pairing,Event]] { + def compare(x: Either[Pairing,Event], y: Either[Pairing,Event]): Int = { + val ot1: Option[DateTime] = x.fold(_.pairedAt, e => Some(e.timestamp)) + val ot2: Option[DateTime] = y.fold(_.pairedAt, e => Some(e.timestamp)) + + (ot1,ot2) match { + case (None,None) => 0 + case (None,Some(_)) => -1 + case (Some(_),None) => 1 + case (Some(t1),Some(t2)) => if(t1 equals t2) 0 else if(t1 isBefore t2) -1 else 1 + } + } + } } private[tournament] case class RawTournament( id: String, name: String, + system: Int = System.default.id, clock: TournamentClock, minutes: Int, minPlayers: Int, @@ -370,6 +407,7 @@ private[tournament] case class RawTournament( startedAt: Option[DateTime] = None, players: List[Player] = Nil, pairings: List[RawPairing] = Nil, + events: List[RawEvent] = Nil, variant: Int = Variant.Standard.id, mode: Int = Mode.Casual.id, schedule: Option[Schedule] = None) { @@ -389,7 +427,8 @@ private[tournament] case class RawTournament( data = data, startedAt = stAt, players = players, - pairings = decodePairings) + pairings = decodePairings, + events = decodeEvents) def finished: Option[Finished] = for { stAt ← startedAt @@ -399,12 +438,14 @@ private[tournament] case class RawTournament( data = data, startedAt = stAt, players = players, - pairings = decodePairings) + pairings = decodePairings, + events = decodeEvents) def enterable: Option[Enterable] = created orElse started private def data = Data( name, + System orDefault system, clock, minutes, minPlayers, @@ -417,6 +458,8 @@ private[tournament] case class RawTournament( private def decodePairings = pairings map (_.decode) flatten + private def decodeEvents = events map (_.decode) flatten + def any: Option[Tournament] = Status(status) flatMap { case Status.Created => created case Status.Started => started @@ -431,6 +474,7 @@ private[tournament] object RawTournament { import play.api.libs.json._ private implicit def pairingTube = RawPairing.tube + private implicit def eventTube = RawEvent.tube private implicit def clockTube = TournamentClock.tube private implicit def scheduleTube = Schedule.tube private implicit def PlayerTube = Player.tube @@ -440,6 +484,8 @@ private[tournament] object RawTournament { "startedAt" -> none[DateTime], "players" -> List[Player](), "pairings" -> List[RawPairing](), + "events" -> List[RawEvent](), + "system" -> System.default.id, "variant" -> Variant.Standard.id, "mode" -> Mode.Casual.id, "schedule" -> none[Schedule]) diff --git a/modules/tournament/src/main/TournamentApi.scala b/modules/tournament/src/main/TournamentApi.scala index aa4fa5d265..024fcbd166 100644 --- a/modules/tournament/src/main/TournamentApi.scala +++ b/modules/tournament/src/main/TournamentApi.scala @@ -32,12 +32,14 @@ private[tournament] final class TournamentApi( lobby: ActorSelection, roundMap: ActorRef) { - def makePairings(oldTour: Started, pairings: NonEmptyList[Pairing]) { + def makePairings(oldTour: Started, pairings: NonEmptyList[Pairing], postEvents: Events) { sequence(oldTour.id) { TournamentRepo startedById oldTour.id flatMap { case Some(tour) => val tour2 = tour addPairings pairings - $update(tour2) >> (pairings map autoPairing(tour2)).sequence map { + val tour3 = if(postEvents.isEmpty) tour2 else { tour2 addEvents postEvents } + + $update(tour3) >> (pairings map autoPairing(tour3)).sequence map { _.list foreach { game => game.tournamentId foreach { tid => sendTo(tid, StartGame(game)) @@ -58,6 +60,7 @@ private[tournament] final class TournamentApi( minPlayers = setup.minPlayers, mode = Mode orDefault setup.mode, password = setup.password, + system = System orDefault setup.system, variant = Variant orDefault setup.variant) $insert(created) >>- (withdrawIds foreach socketReload) >>- diff --git a/modules/tournament/src/main/arena/PairingSystem.scala b/modules/tournament/src/main/arena/PairingSystem.scala new file mode 100644 index 0000000000..c5cffd4744 --- /dev/null +++ b/modules/tournament/src/main/arena/PairingSystem.scala @@ -0,0 +1,81 @@ +package lila.tournament +package arena + +import lila.tournament.{ PairingSystem => AbstractPairingSystem } + +import scala.util.Random +import scala.concurrent.Future + +object PairingSystem extends AbstractPairingSystem { + type P = (String, String) + + def createPairings(tour: Tournament, users: List[String]): Future[(Pairings,Events)] = { + val pairings = tour.pairings + val nbActiveUsers = tour.nbActiveUsers + + if (users.size < 2) + Future.successful((Nil,Nil)) + else { + val idles: List[String] = Random shuffle { + users.toSet diff { (pairings filter (_.playing) flatMap (_.users)).toSet } toList + } + + val ps = pairings.isEmpty.fold( + naivePairings(idles), + (idles.size > 12).fold( + naivePairings(idles), + smartPairings(idles, pairings, nbActiveUsers) + ) + ) + + Future.successful((ps,Nil)) + } + } + + private def naivePairings(users: List[String]) = + Random shuffle users grouped 2 collect { + case List(u1, u2) => Pairing(u1, u2) + } toList + + private def smartPairings(users: List[String], pairings: Pairings, nbActiveUsers: Int): Pairings = { + + def lastOpponent(user: String): Option[String] = + pairings find (_ contains user) flatMap (_ opponentOf user) + + def justPlayedTogether(u1: String, u2: String): Boolean = + lastOpponent(u1) == u2.some && lastOpponent(u2) == u1.some + + def timeSincePlay(u: String): Int = + pairings.takeWhile(_ notContains u).size + + // lower is better + def score(pair: P): Int = pair match { + case (a, b) => justPlayedTogether(a, b).fold( + 100, + -timeSincePlay(a) - timeSincePlay(b)) + } + + (users match { + case x if x.size < 2 => Nil + case List(u1, u2) if nbActiveUsers == 2 => List(u1 -> u2) + case List(u1, u2) if justPlayedTogether(u1, u2) => Nil + case List(u1, u2) => List(u1 -> u2) + case us => allPairCombinations(us) + .map(c => c -> c.map(score).sum) + .sortBy(_._2) + .headOption + .map(_._1) | Nil + }) map Pairing.apply + } + + private def allPairCombinations(list: List[String]): List[List[(String, String)]] = list match { + case a :: rest => for { + b ← rest + init = (a -> b) + nps = allPairCombinations(rest filter (b !=)) + ps ← nps.isEmpty.fold(List(List(init)), nps map (np => init :: np)) + } yield ps + case _ => Nil + } +} + diff --git a/modules/tournament/src/main/Score.scala b/modules/tournament/src/main/arena/ScoringSystem.scala similarity index 53% rename from modules/tournament/src/main/Score.scala rename to modules/tournament/src/main/arena/ScoringSystem.scala index a2f6c12a99..68608a5385 100644 --- a/modules/tournament/src/main/Score.scala +++ b/modules/tournament/src/main/arena/ScoringSystem.scala @@ -1,31 +1,41 @@ package lila.tournament +package arena -case class Score( - win: Option[Boolean], - flag: Score.Flag) { - - val value = this match { - case Score(Some(true), Score.Double) => 4 - case Score(Some(true), _) => 2 - case Score(None, Score.Double) => 2 - case Score(None, _) => 1 - case _ => 0 - } -} - -object Score { - - case class Sheet(scores: List[Score]) { - val total = scores.foldLeft(0)(_ + _.value) - def onFire = Score firstTwoAreWins scores - } +import lila.tournament.{ ScoringSystem => AbstractScoringSystem } +import lila.tournament.{ Score => AbstractScore } +object ScoringSystem extends AbstractScoringSystem { sealed trait Flag case object StreakStarter extends Flag case object Double extends Flag case object Normal extends Flag - def sheet(user: String, tour: Tournament) = Sheet { + case class Score( + win: Option[Boolean], + flag: Flag) extends AbstractScore { + + val value = this match { + case Score(Some(true), Double) => 4 + case Score(Some(true), _) => 2 + case Score(None, Double) => 2 + case Score(None, _) => 1 + case _ => 0 + } + } + + case class Sheet(scores: List[Score]) extends ScoreSheet { + val total = scores.foldLeft(0)(_ + _.value) + def onFire = firstTwoAreWins(scores) + } + + override def rank(tour: Tournament, players: Players): RankedPlayers = { + players.foldLeft(Nil: RankedPlayers) { + case (Nil, p) => (1, p) :: Nil + case (list@((r0, p0) :: _), p) => ((p0.score == p.score).fold(r0, list.size + 1), p) :: list + }.reverse + } + + override def scoreSheet(tour: Tournament, user: String) = Sheet { val filtered = tour userPairings user filter (_.finished) reverse val nexts = (filtered drop 1 map Some.apply) :+ None filtered.zip(nexts).foldLeft(List[Score]()) { diff --git a/modules/tournament/src/main/package.scala b/modules/tournament/src/main/package.scala index ad12dfbaaf..876c6f34cf 100644 --- a/modules/tournament/src/main/package.scala +++ b/modules/tournament/src/main/package.scala @@ -18,9 +18,11 @@ package object tournament extends PackageObject with WithPlay with WithSocket { } } + private[tournament] type Players = List[tournament.Player] + private[tournament] type Pairings = List[tournament.Pairing] - private[tournament] type Players = List[tournament.Player] + private[tournament] type Events = List[tournament.Event] private[tournament] object RandomName { diff --git a/modules/tournament/src/main/swiss/SwissSystem.scala b/modules/tournament/src/main/swiss/SwissSystem.scala new file mode 100644 index 0000000000..cf8d55017d --- /dev/null +++ b/modules/tournament/src/main/swiss/SwissSystem.scala @@ -0,0 +1,204 @@ +package lila.tournament +package swiss + +import org.joda.time.DateTime +import org.joda.time.Duration + +import lila.tournament.{ Score => AbstractScore } +import lila.game.{ Game, GameRepo } + +import scala.concurrent.Future +import scala.util.Try + +import scalaz.NonEmptyList + +object SwissSystem extends PairingSystem with ScoringSystem { + private val MinTimeBetweenRounds = Duration.standardSeconds(10L) + + sealed abstract class Score(val value: Int, val repr: String) extends AbstractScore + case object Win extends Score(2, "1") + case object Loss extends Score(0, "0") + case object Draw extends Score(1, "½") + case object Byed extends Score(2, "B") + case object Absent extends Score(0, "—") + case object Ongoing extends Score(0, "*") + + case class Sheet(scores: List[Score], total: Int, neustadtl: Int) extends ScoreSheet { + private def f(d: Int): String = d match { + case 0 => "" + case 1 => "¼" + case 2 => "½" + case 3 => "¾" + } + lazy val totalRepr: String = (total/2) + f(2*(total%2)) + lazy val neustadtlRepr: String = (neustadtl/4) + f(neustadtl%4) + + def compare(other: Sheet): Int = { + if(total > other.total) 1 + else if(total < other.total) -1 + else if(neustadtl > other.neustadtl) 1 + else if(neustadtl < other.neustadtl) -1 + else 0 + } + } + private val BlankSheet = Sheet(Nil, 0, 0) + + private type STour = swisssystem.Tournament[String] + private type Sheets = Map[String,Sheet] + + private object SheetOrdering extends Ordering[Sheet] { + def compare(s1: Sheet, s2: Sheet): Int = s1 compare s2 + } + + // I feel like this must exist in the stdlib somewhere... + // Maybe in scalaz? It's like String.split, but for lists... + private def split[T](l: List[T], p: T=>Boolean): List[List[T]] = { + def s(l: List[T], p: T=>Boolean): NonEmptyList[List[T]] = l match { + case Nil => NonEmptyList(Nil, Nil) + case x :: xs if p(x) => NonEmptyList(Nil, s(xs, p).list: _*) + case x :: xs => + val rec = s(xs, p) + val (r, rs) = (rec.head, rec.tail) + NonEmptyList(x :: r, rs: _*) + } + s(l,p).list.filterNot(_.isEmpty) + } + + override def scoreSheets(tour: Tournament): Map[String,Sheet] = { + fromHistory(tour)._2 + } + + override def rank(tour: Tournament, players: Players): RankedPlayers = { + val ss = scoreSheets(tour) + + def r(ps: Players) = { + val withSheets = ps map { p => + (p, ss.getOrElse(p.id, BlankSheet)) + } + val sorted = withSheets.sortBy(_._2)(SheetOrdering).reverse + + // yeurk. + val ranked = sorted.foldLeft[(RankedPlayers,Sheet)]((Nil,BlankSheet)) { + case ((Nil,_),(p,s)) => ((1, p) :: Nil, s) + case ((l@(r0::_),s0), (p,s)) if s0.compare(s) == 0 => ((r0._1, p) :: l, s0) + case ((l,_),(p, s)) => ((l.size + 1, p) :: l, s) + } + ranked._1.reverse + } + + val (active,inactive) = players.partition(_.active) + r(active) ::: r(inactive) + } + + override def createPairings(tour: Tournament, users: List[String]): Future[(Pairings,Events)] = { + val failed = (Nil,Nil) + val failedFuture = Future.successful(failed) // Oh the irony. + + // Notice how this doesn't use users: we get to pair players who haven't returned to the lobby yet. + val toPair = tour.activePlayers.map(_.id).toSet + + if(toPair.size < 2) { + failedFuture + } else if(tour.pairings.exists(_.playing)) { + // Can't pair if games are still going on. + failedFuture + } else { + val now = DateTime.now + + val pairingTimes: Option[NonEmptyList[DateTime]] = tour.pairings.flatMap(_.pairedAt).toNel + + val lastRoundGames: Future[List[Game]] = pairingTimes.fold(Future.successful(Nil:List[Game])) { pts => + val mostRecentPairingTime: DateTime = pts.maxBy(_.getMillis) // safe ! + val lastRoundGameIds: List[String] = tour.pairings.collect { + case p if p.pairedAt == Some(mostRecentPairingTime) => p.gameId + } + GameRepo.games(lastRoundGameIds) + } + + lastRoundGames map { games => + val updateTimes: List[DateTime] = games.flatMap(_.updatedAt) + if(updateTimes.exists(t => t.plus(MinTimeBetweenRounds).isAfter(now))) { + // Too soon! + failed + } else { + val (tt, _) = fromHistory(tour) + + tt flatMap { t => + t.pairings(toPair) map { p => + val ps = p.pairs.map { + case (p1,p2) => Pairing(p1,p2,now) + } + val roundEnd = RoundEnd(now.plusMillis(1)) + val events = p.unpaired map { u => + Bye(u, now) :: roundEnd :: Nil + } getOrElse { + roundEnd :: Nil + } + (ps,events) + } + } getOrElse { + failed + } + } + } + } + } + + // Make sure you don't run that too often. For example, use scoreSheets rather than scoreSheet... + private def fromHistory(tour: Tournament): (Try[STour],Sheets) = { + val players: Map[String,Int] = tour.players.map(p => (p.id -> p.rating)).toMap + + val history = tour.pairingsAndEvents + + val historyByRound = split[Either[Pairing,Event]](history, _ match { + case Right(RoundEnd(_)) => true + case _ => false + }) + + val listsByRound: List[(Pairings,Events)] = historyByRound.map { r => + val (ls,rs) = r.partition(_.isLeft) + (ls.map(_.left.get), rs.map(_.right.get)) + } + + val pairingsByRound: List[List[Pairing]] = listsByRound.map(_._1) + val eventsByRound: List[List[Event]] = listsByRound.map(_._2) + + // Creating the swisssystem instance... + val t = Try(swisssystem.Tournament.create(players)) + val t2 = pairingsByRound.flatten.filter(_.finished).foldLeft(t) { (tt, p) => + tt flatMap { t => + val p1 = p.user1 + val p2 = p.user2 + val (v1,v2) = if(p.draw) (1,1) else if(p.wonBy(p1)) (2,0) else (0,2) + t.withResult(p1, v1, p2, v2) + } + } + val t3 = eventsByRound.flatten.collect { case Bye(u, _) => u }.foldLeft(t2) { (tt, p) => + tt flatMap { t => t.withBye(p, 2) } + } + + val ss: Map[String,Sheet] = players.keySet.toList map { player => + val scores = listsByRound.map { r => + r._1 find(_.contains(player)) map { p => + if(p.playing) Ongoing + else if(p.wonBy(player)) Win + else if(p.draw) Draw else Loss + } getOrElse { + if(r._2.exists(e => e match { + case Bye(u, _) if u == player => true + case _ => false + })) Byed else Absent + } + } reverse // reverse is for compatibility... the view reverses it again. + + val total = scores.map(_.value).sum + val neustadtl = t3.map(_.performances.getOrElse(player, 0)).getOrElse(0) + val sheet = Sheet(scores, total, neustadtl) + + (player -> sheet) + } toMap + + (t3, ss) + } +} + diff --git a/project/Build.scala b/project/Build.scala index 628929d222..c1a6e8718a 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -40,7 +40,8 @@ object ApplicationBuild extends Build { gameSearch, timeline, forum, forumSearch, team, teamSearch, ai, analyse, mod, monitor, site, round, lobby, setup, importer, tournament, pool, relation, report, pref, // simulation, - evaluation, chat, puzzle, tv, coordinate, blog, donation, qa) + evaluation, chat, puzzle, tv, coordinate, blog, donation, qa, + swisssystem) lazy val moduleRefs = modules map projectToRef lazy val moduleCPDeps = moduleRefs map { new sbt.ClasspathDependency(_, None) } @@ -155,7 +156,7 @@ object ApplicationBuild extends Build { ) lazy val setup = project("setup", Seq( - common, db, memo, hub, socket, chess, game, user, lobby)).settings( + common, db, memo, hub, socket, chess, game, tournament, user, lobby)).settings( libraryDependencies ++= provided(play.api, RM, PRM) ) @@ -164,7 +165,7 @@ object ApplicationBuild extends Build { ) lazy val tournament = project("tournament", Seq( - common, hub, socket, chess, game, round, security, chat, memo)).settings( + common, hub, socket, chess, game, round, security, chat, memo, swisssystem)).settings( libraryDependencies ++= provided(play.api, RM, PRM) ) @@ -255,4 +256,6 @@ object ApplicationBuild extends Build { ) lazy val chess = project("chess") + + lazy val swisssystem = project("swisssystem") }