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.pull/91/head
parent
7638923d1c
commit
f09a2e9893
|
@ -4,3 +4,6 @@
|
||||||
[submodule "public/vendor/tagmanager"]
|
[submodule "public/vendor/tagmanager"]
|
||||||
path = public/vendor/tagmanager
|
path = public/vendor/tagmanager
|
||||||
url = https://github.com/max-favilli/tagmanager
|
url = https://github.com/max-favilli/tagmanager
|
||||||
|
[submodule "modules/swisssystem"]
|
||||||
|
path = modules/swisssystem
|
||||||
|
url = https://github.com/psuter/swisssystem
|
||||||
|
|
|
@ -4,6 +4,7 @@ package templating
|
||||||
import chess.{ Mode, Variant, Speed }
|
import chess.{ Mode, Variant, Speed }
|
||||||
import lila.setup._
|
import lila.setup._
|
||||||
import lila.api.Context
|
import lila.api.Context
|
||||||
|
import lila.tournament.System
|
||||||
|
|
||||||
trait SetupHelper { self: I18nHelper =>
|
trait SetupHelper { self: I18nHelper =>
|
||||||
|
|
||||||
|
@ -12,6 +13,11 @@ trait SetupHelper { self: I18nHelper =>
|
||||||
Mode.Rated.id.toString -> trans.rated.str()
|
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(
|
def translatedVariantChoices(implicit ctx: Context) = List(
|
||||||
Variant.Standard.id.toString -> trans.standard.str(),
|
Variant.Standard.id.toString -> trans.standard.str(),
|
||||||
Variant.Chess960.id.toString -> Variant.Chess960.name.capitalize
|
Variant.Chess960.id.toString -> Variant.Chess960.name.capitalize
|
||||||
|
|
|
@ -3,8 +3,8 @@ package templating
|
||||||
|
|
||||||
import controllers.routes
|
import controllers.routes
|
||||||
import lila.api.Context
|
import lila.api.Context
|
||||||
import lila.tournament.Tournament
|
import lila.tournament.{ Tournament, System }
|
||||||
import lila.user.User
|
import lila.user.{ User, UserContext }
|
||||||
|
|
||||||
import play.api.libs.json.Json
|
import play.api.libs.json.Json
|
||||||
import play.twirl.api.Html
|
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)
|
val name = if (tour.scheduled) tour.name else trans.xTournament(tour.name)
|
||||||
s"""<a data-icon="g" class="$cssClass" href="$url"> $name</a>"""
|
s"""<a data-icon="g" class="$cssClass" href="$url"> $name</a>"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def systemName(sys: System)(implicit ctx: UserContext) = sys match {
|
||||||
|
case System.Arena => trans.arena.str()
|
||||||
|
case System.Swiss => trans.swiss.str()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
@(pov: Pov, tour: Option[lila.tournament.Tournament], withTourStanding: Boolean)(implicit ctx: Context)
|
@(pov: Pov, tour: Option[lila.tournament.Tournament], withTourStanding: Boolean)(implicit ctx: Context)
|
||||||
|
|
||||||
@import pov._
|
@import pov._
|
||||||
|
@import lila.tournament.arena
|
||||||
|
|
||||||
<div class="goodies">
|
<div class="goodies">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
@ -69,10 +70,13 @@
|
||||||
@t.rankedPlayers.map {
|
@t.rankedPlayers.map {
|
||||||
case (rank, player) => {
|
case (rank, player) => {
|
||||||
@defining((
|
@defining((
|
||||||
if(t.isFinished && rank == 1) "winner" else if (player.withdraw) "withdraw" else "",
|
t scoreSheet player,
|
||||||
t scoreSheet player
|
// TODO FIXME that's a little ugly I must say.
|
||||||
)) {
|
t.system.scoringSystem match {
|
||||||
case (flag, scoreSheet) => {
|
case ss @ arena.ScoringSystem => ss.scoreSheet(t, player.id).onFire
|
||||||
|
case _ => false
|
||||||
|
})) {
|
||||||
|
case (scoreSheet, onFire) => {
|
||||||
<tr @if(ctx.userId.exists(player.id==)) { class="me" }>
|
<tr @if(ctx.userId.exists(player.id==)) { class="me" }>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
@if(player.withdraw) {
|
@if(player.withdraw) {
|
||||||
|
@ -87,7 +91,7 @@
|
||||||
@userInfosLink(player.id, none, withOnline = false)
|
@userInfosLink(player.id, none, withOnline = false)
|
||||||
</td>
|
</td>
|
||||||
<td class="total">
|
<td class="total">
|
||||||
<strong@if(scoreSheet.onFire) { class="is-gold" data-icon="Q" }>@scoreSheet.total</strong>
|
<strong@if(onFire) { class="is-gold" data-icon="Q" }>@scoreSheet.total</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td class="around-bar" colspan="3"><div class="bar" data-value="@scoreSheet.total"></div></td></tr>
|
<tr><td class="around-bar" colspan="3"><div class="bar" data-value="@scoreSheet.total"></div></td></tr>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
@(tour: lila.tournament.StartedOrFinished)(implicit ctx: Context)
|
@(tour: lila.tournament.StartedOrFinished)(implicit ctx: Context)
|
||||||
|
|
||||||
|
@import lila.tournament.arena.ScoringSystem
|
||||||
|
|
||||||
<div class="standing_wrap scroll-shadow-soft">
|
<div class="standing_wrap scroll-shadow-soft">
|
||||||
<table class="slist standing @if(tour.scheduled) { scheduled }">
|
<table class="slist standing @if(tour.scheduled) { scheduled }">
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -17,7 +19,7 @@
|
||||||
case (rank, player) => {
|
case (rank, player) => {
|
||||||
@defining((
|
@defining((
|
||||||
if(tour.isFinished && rank == 1) "winner" else if (player.withdraw) "withdraw" else "",
|
if(tour.isFinished && rank == 1) "winner" else if (player.withdraw) "withdraw" else "",
|
||||||
tour scoreSheet player
|
ScoringSystem.scoreSheet(tour, player.id)
|
||||||
)) {
|
)) {
|
||||||
case (flag, scoreSheet) => {
|
case (flag, scoreSheet) => {
|
||||||
<tr @if(ctx.userId.exists(player.id==)) { class="me" }>
|
<tr @if(ctx.userId.exists(player.id==)) { class="me" }>
|
|
@ -31,6 +31,10 @@ moreJs = moreJs) {
|
||||||
<p class="error">@error.message</p>
|
<p class="error">@error.message</p>
|
||||||
}
|
}
|
||||||
<table>
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th><label for="@form("system").id">@trans.system()</label></th>
|
||||||
|
<td>@base.select(form("system"), translatedSystemChoices)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="@form("variant").id">@trans.variant()</label></th>
|
<th><label for="@form("variant").id">@trans.variant()</label></th>
|
||||||
<td>@base.select(form("variant"), translatedVariantChoices)</td>
|
<td>@base.select(form("variant"), translatedVariantChoices)</td>
|
||||||
|
|
|
@ -24,6 +24,8 @@
|
||||||
@variantName(tour.variant).capitalize,
|
@variantName(tour.variant).capitalize,
|
||||||
@{ tour.rated.fold(trans.rated(), trans.casual()) }
|
@{ tour.rated.fold(trans.rated(), trans.casual()) }
|
||||||
<br /><br />
|
<br /><br />
|
||||||
|
@trans.system(): @systemName(tour.system).capitalize
|
||||||
|
<br /><br />
|
||||||
@trans.duration(): @tour.minutes minutes
|
@trans.duration(): @tour.minutes minutes
|
||||||
@if(tour.isRunning) {
|
@if(tour.isRunning) {
|
||||||
<br /><br />
|
<br /><br />
|
||||||
|
|
|
@ -4,6 +4,14 @@
|
||||||
|
|
||||||
<h1 data-icon="g"> @if(tour.scheduled){@tour.name} else {@trans.xTournament(tour.name)}</h1>
|
<h1 data-icon="g"> @if(tour.scheduled){@tour.name} else {@trans.xTournament(tour.name)}</h1>
|
||||||
|
|
||||||
@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)
|
@tournament.games(games)
|
||||||
|
|
|
@ -13,6 +13,13 @@
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
@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)
|
@tournament.games(games)
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
@(tour: lila.tournament.StartedOrFinished)(implicit ctx: Context)
|
||||||
|
|
||||||
|
@import lila.tournament.swiss.SwissSystem
|
||||||
|
|
||||||
|
<div class="standing_wrap scroll-shadow-soft">
|
||||||
|
<table class="slist standing @if(tour.scheduled) { scheduled }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="large">@trans.standing() (@tour.nbPlayers)</th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@defining(SwissSystem.scoreSheets(tour)) {
|
||||||
|
case scoreSheets => {
|
||||||
|
@tour.rankedPlayers.map {
|
||||||
|
case (rank, player) => {
|
||||||
|
@defining(scoreSheets(player.id)) {
|
||||||
|
case scoreSheet => {
|
||||||
|
<tr @if(ctx.userId.exists(player.id==)) { class="me" }>
|
||||||
|
<td class="name">
|
||||||
|
@if(player.withdraw) {
|
||||||
|
<span data-icon="b" title="@trans.withdraw()"></span>
|
||||||
|
} else {
|
||||||
|
@if(tour.isFinished && rank == 1) {
|
||||||
|
<span data-icon="g" title="@trans.winner()"></span>
|
||||||
|
} else {
|
||||||
|
<span class="rank">@rank</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@userInfosLink(player.id, none, withOnline = false)
|
||||||
|
</td>
|
||||||
|
<td class="sheet">
|
||||||
|
@scoreSheet.scores.reverse.map { score =>
|
||||||
|
<span class="normal">@score.repr</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="total">
|
||||||
|
<strong>@scoreSheet.totalRepr</strong> (@scoreSheet.neustadtlRepr)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="around-bar" colspan="3"><div class="bar" data-value="@scoreSheet.total"></div></td></tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
|
@ -111,6 +111,9 @@ unlimited=Unlimited
|
||||||
mode=Mode
|
mode=Mode
|
||||||
casual=Casual
|
casual=Casual
|
||||||
rated=Rated
|
rated=Rated
|
||||||
|
system=System
|
||||||
|
arena=Arena
|
||||||
|
swiss=Swiss
|
||||||
thisGameIsRated=This game is rated
|
thisGameIsRated=This game is rated
|
||||||
rematch=Rematch
|
rematch=Rematch
|
||||||
rematchOfferSent=Rematch offer sent
|
rematchOfferSent=Rematch offer sent
|
||||||
|
|
|
@ -135,6 +135,9 @@ final class I18nKeys(translator: Translator) {
|
||||||
val `mode` = new Key("mode")
|
val `mode` = new Key("mode")
|
||||||
val `casual` = new Key("casual")
|
val `casual` = new Key("casual")
|
||||||
val `rated` = new Key("rated")
|
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 `thisGameIsRated` = new Key("thisGameIsRated")
|
||||||
val `rematch` = new Key("rematch")
|
val `rematch` = new Key("rematch")
|
||||||
val `rematchOfferSent` = new Key("rematchOfferSent")
|
val `rematchOfferSent` = new Key("rematchOfferSent")
|
||||||
|
|
|
@ -5,6 +5,7 @@ import chess.{ Game => ChessGame, Board, Situation, Variant, Clock, Speed }
|
||||||
|
|
||||||
import lila.game.{ GameRepo, Game, Pov }
|
import lila.game.{ GameRepo, Game, Pov }
|
||||||
import lila.lobby.Color
|
import lila.lobby.Color
|
||||||
|
import lila.tournament.{ System => TournamentSystem }
|
||||||
|
|
||||||
private[setup] trait Config {
|
private[setup] trait Config {
|
||||||
|
|
||||||
|
@ -81,6 +82,8 @@ trait Positional { self: Config =>
|
||||||
object Config extends BaseConfig
|
object Config extends BaseConfig
|
||||||
|
|
||||||
trait 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 variants = List(Variant.Standard.id, Variant.Chess960.id)
|
||||||
val variantDefault = Variant.Standard
|
val variantDefault = Variant.Standard
|
||||||
|
|
|
@ -22,4 +22,5 @@ object Mappings {
|
||||||
val level = number.verifying(AiConfig.levels contains _)
|
val level = number.verifying(AiConfig.levels contains _)
|
||||||
val speed = number.verifying(Config.speeds contains _)
|
val speed = number.verifying(Config.speeds contains _)
|
||||||
val fen = optional(nonEmptyText)
|
val fen = optional(nonEmptyText)
|
||||||
|
val system = number.verifying(Config.systems contains _)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit a4168747954cb70f4bb34a45af0b72c5ccad9a9c
|
|
@ -35,6 +35,7 @@ final class DataForm(isDev: Boolean) {
|
||||||
"clockIncrement" -> numberIn(clockIncrementChoices),
|
"clockIncrement" -> numberIn(clockIncrementChoices),
|
||||||
"minutes" -> numberIn(minuteChoices),
|
"minutes" -> numberIn(minuteChoices),
|
||||||
"minPlayers" -> numberIn(minPlayerChoices),
|
"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 _),
|
"variant" -> number.verifying(Set(Variant.Standard.id, Variant.Chess960.id) contains _),
|
||||||
"mode" -> number.verifying(Mode.all map (_.id) contains _),
|
"mode" -> number.verifying(Mode.all map (_.id) contains _),
|
||||||
"password" -> optional(nonEmptyText)
|
"password" -> optional(nonEmptyText)
|
||||||
|
@ -46,6 +47,7 @@ final class DataForm(isDev: Boolean) {
|
||||||
clockIncrement = clockIncrementDefault,
|
clockIncrement = clockIncrementDefault,
|
||||||
minutes = minuteDefault,
|
minutes = minuteDefault,
|
||||||
minPlayers = minPlayerDefault,
|
minPlayers = minPlayerDefault,
|
||||||
|
system = System.default.id,
|
||||||
variant = Variant.Standard.id,
|
variant = Variant.Standard.id,
|
||||||
password = none,
|
password = none,
|
||||||
mode = Mode.Casual.id)
|
mode = Mode.Casual.id)
|
||||||
|
@ -60,6 +62,7 @@ private[tournament] case class TournamentSetup(
|
||||||
clockIncrement: Int,
|
clockIncrement: Int,
|
||||||
minutes: Int,
|
minutes: Int,
|
||||||
minPlayers: Int,
|
minPlayers: Int,
|
||||||
|
system: Int,
|
||||||
variant: Int,
|
variant: Int,
|
||||||
mode: Int,
|
mode: Int,
|
||||||
password: Option[String]) {
|
password: Option[String]) {
|
||||||
|
|
|
@ -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]
|
||||||
|
)
|
||||||
|
}
|
|
@ -60,8 +60,12 @@ private[tournament] final class Organizer(
|
||||||
if (!tour.isAlmostFinished) {
|
if (!tour.isAlmostFinished) {
|
||||||
withUserIds(tour.id) { ids =>
|
withUserIds(tour.id) { ids =>
|
||||||
(tour.activeUserIds intersect ids) |> { users =>
|
(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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
package lila.tournament
|
package lila.tournament
|
||||||
|
|
||||||
import scala.util.Random
|
|
||||||
|
|
||||||
import chess.Color
|
import chess.Color
|
||||||
import lila.game.{ PovRef, IdGenerator }
|
import lila.game.{ PovRef, IdGenerator }
|
||||||
|
|
||||||
|
import org.joda.time.DateTime
|
||||||
|
|
||||||
case class Pairing(
|
case class Pairing(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
status: chess.Status,
|
status: chess.Status,
|
||||||
user1: String,
|
user1: String,
|
||||||
user2: String,
|
user2: String,
|
||||||
winner: Option[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 users = List(user1, user2)
|
||||||
def usersPair = user1 -> user2
|
def usersPair = user1 -> user2
|
||||||
|
@ -51,90 +52,29 @@ case class Pairing(
|
||||||
}
|
}
|
||||||
|
|
||||||
private[tournament] object Pairing {
|
private[tournament] object Pairing {
|
||||||
|
|
||||||
type P = (String, String)
|
type P = (String, String)
|
||||||
|
|
||||||
def apply(users: P): Pairing = apply(users._1, users._2)
|
def apply(us: P): Pairing = apply(us._1, us._2)
|
||||||
def apply(user1: String, user2: String): Pairing = new Pairing(
|
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,
|
gameId = IdGenerator.game,
|
||||||
status = chess.Status.Created,
|
status = chess.Status.Created,
|
||||||
user1 = user1,
|
user1 = u1,
|
||||||
user2 = user2,
|
user2 = u2,
|
||||||
winner = none,
|
winner = none,
|
||||||
turns = none)
|
turns = none,
|
||||||
|
pairedAt = pa)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
def decode: Option[Pairing] = for {
|
||||||
status ← chess.Status(s)
|
status ← chess.Status(s)
|
||||||
user1 ← u.lift(0)
|
user1 ← u.lift(0)
|
||||||
user2 ← u.lift(1)
|
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 {
|
private[tournament] object RawPairing {
|
||||||
|
@ -145,7 +85,8 @@ private[tournament] object RawPairing {
|
||||||
|
|
||||||
private def defaults = Json.obj(
|
private def defaults = Json.obj(
|
||||||
"w" -> none[String],
|
"w" -> none[String],
|
||||||
"t" -> none[Int])
|
"t" -> none[Int],
|
||||||
|
"p" -> none[DateTime])
|
||||||
|
|
||||||
private[tournament] val tube = JsTube(
|
private[tournament] val tube = JsTube(
|
||||||
(__.json update merge(defaults)) andThen Json.reads[RawPairing],
|
(__.json update merge(defaults)) andThen Json.reads[RawPairing],
|
||||||
|
|
|
@ -25,7 +25,7 @@ private[tournament] object Player {
|
||||||
rating = user.rating)
|
rating = user.rating)
|
||||||
|
|
||||||
private[tournament] def refresh(tour: Tournament): Players = tour.players map { p =>
|
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 =>
|
} sortBy { p =>
|
||||||
p.withdraw.fold(Int.MaxValue, 0) - p.score
|
p.withdraw.fold(Int.MaxValue, 0) - p.score
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -8,8 +8,10 @@ import chess.{ Variant, Mode }
|
||||||
import lila.game.PovRef
|
import lila.game.PovRef
|
||||||
import lila.user.User
|
import lila.user.User
|
||||||
|
|
||||||
|
|
||||||
private[tournament] case class Data(
|
private[tournament] case class Data(
|
||||||
name: String,
|
name: String,
|
||||||
|
system: System,
|
||||||
clock: TournamentClock,
|
clock: TournamentClock,
|
||||||
minutes: Int,
|
minutes: Int,
|
||||||
minPlayers: Int,
|
minPlayers: Int,
|
||||||
|
@ -28,6 +30,7 @@ sealed trait Tournament {
|
||||||
def players: Players
|
def players: Players
|
||||||
def winner: Option[Player]
|
def winner: Option[Player]
|
||||||
def pairings: List[Pairing]
|
def pairings: List[Pairing]
|
||||||
|
def events: List[Event]
|
||||||
def isOpen: Boolean = false
|
def isOpen: Boolean = false
|
||||||
def isRunning: Boolean = false
|
def isRunning: Boolean = false
|
||||||
def isFinished: Boolean = false
|
def isFinished: Boolean = false
|
||||||
|
@ -38,6 +41,7 @@ sealed trait Tournament {
|
||||||
def minutes = data.minutes
|
def minutes = data.minutes
|
||||||
lazy val duration = new Duration(minutes * 60 * 1000)
|
lazy val duration = new Duration(minutes * 60 * 1000)
|
||||||
|
|
||||||
|
def system = data.system
|
||||||
def variant = data.variant
|
def variant = data.variant
|
||||||
def mode = data.mode
|
def mode = data.mode
|
||||||
def rated = mode.rated
|
def rated = mode.rated
|
||||||
|
@ -72,7 +76,11 @@ sealed trait Tournament {
|
||||||
|
|
||||||
def userPairings(user: String) = pairings filter (_ contains user)
|
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 {
|
sealed trait Enterable extends Tournament {
|
||||||
|
@ -100,16 +108,13 @@ sealed trait Enterable extends Tournament {
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed trait StartedOrFinished extends Tournament {
|
sealed trait StartedOrFinished extends Tournament {
|
||||||
|
type RankedPlayers = List[(Int,Player)]
|
||||||
|
|
||||||
def startedAt: DateTime
|
def startedAt: DateTime
|
||||||
def withPlayers(s: Players): StartedOrFinished
|
def withPlayers(s: Players): StartedOrFinished
|
||||||
def refreshPlayers: StartedOrFinished
|
def refreshPlayers: StartedOrFinished
|
||||||
|
|
||||||
type RankedPlayers = List[(Int, Player)]
|
def rankedPlayers: RankedPlayers = system.scoringSystem.rank(this, players)
|
||||||
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 winner = players.headOption
|
def winner = players.headOption
|
||||||
def winnerUserId = winner map (_.id)
|
def winnerUserId = winner map (_.id)
|
||||||
|
@ -121,6 +126,7 @@ sealed trait StartedOrFinished extends Tournament {
|
||||||
id = id,
|
id = id,
|
||||||
status = status.id,
|
status = status.id,
|
||||||
name = data.name,
|
name = data.name,
|
||||||
|
system = data.system.id,
|
||||||
clock = data.clock,
|
clock = data.clock,
|
||||||
minutes = data.minutes,
|
minutes = data.minutes,
|
||||||
minPlayers = data.minPlayers,
|
minPlayers = data.minPlayers,
|
||||||
|
@ -132,7 +138,8 @@ sealed trait StartedOrFinished extends Tournament {
|
||||||
startedAt = startedAt.some,
|
startedAt = startedAt.some,
|
||||||
schedule = data.schedule,
|
schedule = data.schedule,
|
||||||
players = players,
|
players = players,
|
||||||
pairings = pairings map (_.encode))
|
pairings = pairings map (_.encode),
|
||||||
|
events = events map (_.encode))
|
||||||
|
|
||||||
def finishedAt = startedAt + duration
|
def finishedAt = startedAt + duration
|
||||||
}
|
}
|
||||||
|
@ -154,12 +161,15 @@ case class Created(
|
||||||
|
|
||||||
def pairings = Nil
|
def pairings = Nil
|
||||||
|
|
||||||
|
def events = Nil
|
||||||
|
|
||||||
def winner = None
|
def winner = None
|
||||||
|
|
||||||
def encode = new RawTournament(
|
def encode = new RawTournament(
|
||||||
id = id,
|
id = id,
|
||||||
status = Status.Created.id,
|
status = Status.Created.id,
|
||||||
name = data.name,
|
name = data.name,
|
||||||
|
system = data.system.id,
|
||||||
clock = data.clock,
|
clock = data.clock,
|
||||||
variant = data.variant.id,
|
variant = data.variant.id,
|
||||||
mode = data.mode.id,
|
mode = data.mode.id,
|
||||||
|
@ -180,7 +190,7 @@ case class Created(
|
||||||
|
|
||||||
def startIfReady = enoughPlayersToStart option start
|
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, _) }
|
def asScheduled = schedule map { Scheduled(this, _) }
|
||||||
|
|
||||||
|
@ -201,7 +211,8 @@ case class Started(
|
||||||
data: Data,
|
data: Data,
|
||||||
startedAt: DateTime,
|
startedAt: DateTime,
|
||||||
players: Players,
|
players: Players,
|
||||||
pairings: List[Pairing]) extends StartedOrFinished with Enterable {
|
pairings: List[Pairing],
|
||||||
|
events: List[Event]) extends StartedOrFinished with Enterable {
|
||||||
|
|
||||||
override def isRunning = true
|
override def isRunning = true
|
||||||
|
|
||||||
|
@ -212,6 +223,9 @@ case class Started(
|
||||||
pairings = pairings map { p => (p.gameId == gameId).fold(f(p), p) }
|
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 readyToFinish = (remainingSeconds == 0) || (nbActiveUsers < 2)
|
||||||
|
|
||||||
def remainingSeconds: Float = math.max(0f,
|
def remainingSeconds: Float = math.max(0f,
|
||||||
|
@ -240,7 +254,8 @@ case class Started(
|
||||||
data = tour.data,
|
data = tour.data,
|
||||||
startedAt = tour.startedAt,
|
startedAt = tour.startedAt,
|
||||||
players = tour.players,
|
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(
|
def withdraw(userId: String): Valid[Started] = contains(userId).fold(
|
||||||
|
@ -277,7 +292,8 @@ case class Finished(
|
||||||
data: Data,
|
data: Data,
|
||||||
startedAt: DateTime,
|
startedAt: DateTime,
|
||||||
players: Players,
|
players: Players,
|
||||||
pairings: List[Pairing]) extends StartedOrFinished {
|
pairings: List[Pairing],
|
||||||
|
events: List[Event]) extends StartedOrFinished {
|
||||||
|
|
||||||
override def isFinished = true
|
override def isFinished = true
|
||||||
|
|
||||||
|
@ -324,12 +340,14 @@ object Tournament {
|
||||||
clock: TournamentClock,
|
clock: TournamentClock,
|
||||||
minutes: Int,
|
minutes: Int,
|
||||||
minPlayers: Int,
|
minPlayers: Int,
|
||||||
|
system: System,
|
||||||
variant: Variant,
|
variant: Variant,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
password: Option[String]): Created = Created(
|
password: Option[String]): Created = Created(
|
||||||
id = Random nextStringUppercase 8,
|
id = Random nextStringUppercase 8,
|
||||||
data = Data(
|
data = Data(
|
||||||
name = RandomName(),
|
name = RandomName(),
|
||||||
|
system = system,
|
||||||
clock = clock,
|
clock = clock,
|
||||||
createdBy = createdBy.id,
|
createdBy = createdBy.id,
|
||||||
createdAt = DateTime.now,
|
createdAt = DateTime.now,
|
||||||
|
@ -345,6 +363,7 @@ object Tournament {
|
||||||
id = Random nextStringUppercase 8,
|
id = Random nextStringUppercase 8,
|
||||||
data = Data(
|
data = Data(
|
||||||
name = sched.name,
|
name = sched.name,
|
||||||
|
system = System.default,
|
||||||
clock = Schedule clockFor sched,
|
clock = Schedule clockFor sched,
|
||||||
createdBy = "lichess",
|
createdBy = "lichess",
|
||||||
createdAt = DateTime.now,
|
createdAt = DateTime.now,
|
||||||
|
@ -355,11 +374,29 @@ object Tournament {
|
||||||
schedule = Some(sched),
|
schedule = Some(sched),
|
||||||
minPlayers = 0),
|
minPlayers = 0),
|
||||||
players = List())
|
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(
|
private[tournament] case class RawTournament(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
system: Int = System.default.id,
|
||||||
clock: TournamentClock,
|
clock: TournamentClock,
|
||||||
minutes: Int,
|
minutes: Int,
|
||||||
minPlayers: Int,
|
minPlayers: Int,
|
||||||
|
@ -370,6 +407,7 @@ private[tournament] case class RawTournament(
|
||||||
startedAt: Option[DateTime] = None,
|
startedAt: Option[DateTime] = None,
|
||||||
players: List[Player] = Nil,
|
players: List[Player] = Nil,
|
||||||
pairings: List[RawPairing] = Nil,
|
pairings: List[RawPairing] = Nil,
|
||||||
|
events: List[RawEvent] = Nil,
|
||||||
variant: Int = Variant.Standard.id,
|
variant: Int = Variant.Standard.id,
|
||||||
mode: Int = Mode.Casual.id,
|
mode: Int = Mode.Casual.id,
|
||||||
schedule: Option[Schedule] = None) {
|
schedule: Option[Schedule] = None) {
|
||||||
|
@ -389,7 +427,8 @@ private[tournament] case class RawTournament(
|
||||||
data = data,
|
data = data,
|
||||||
startedAt = stAt,
|
startedAt = stAt,
|
||||||
players = players,
|
players = players,
|
||||||
pairings = decodePairings)
|
pairings = decodePairings,
|
||||||
|
events = decodeEvents)
|
||||||
|
|
||||||
def finished: Option[Finished] = for {
|
def finished: Option[Finished] = for {
|
||||||
stAt ← startedAt
|
stAt ← startedAt
|
||||||
|
@ -399,12 +438,14 @@ private[tournament] case class RawTournament(
|
||||||
data = data,
|
data = data,
|
||||||
startedAt = stAt,
|
startedAt = stAt,
|
||||||
players = players,
|
players = players,
|
||||||
pairings = decodePairings)
|
pairings = decodePairings,
|
||||||
|
events = decodeEvents)
|
||||||
|
|
||||||
def enterable: Option[Enterable] = created orElse started
|
def enterable: Option[Enterable] = created orElse started
|
||||||
|
|
||||||
private def data = Data(
|
private def data = Data(
|
||||||
name,
|
name,
|
||||||
|
System orDefault system,
|
||||||
clock,
|
clock,
|
||||||
minutes,
|
minutes,
|
||||||
minPlayers,
|
minPlayers,
|
||||||
|
@ -417,6 +458,8 @@ private[tournament] case class RawTournament(
|
||||||
|
|
||||||
private def decodePairings = pairings map (_.decode) flatten
|
private def decodePairings = pairings map (_.decode) flatten
|
||||||
|
|
||||||
|
private def decodeEvents = events map (_.decode) flatten
|
||||||
|
|
||||||
def any: Option[Tournament] = Status(status) flatMap {
|
def any: Option[Tournament] = Status(status) flatMap {
|
||||||
case Status.Created => created
|
case Status.Created => created
|
||||||
case Status.Started => started
|
case Status.Started => started
|
||||||
|
@ -431,6 +474,7 @@ private[tournament] object RawTournament {
|
||||||
import play.api.libs.json._
|
import play.api.libs.json._
|
||||||
|
|
||||||
private implicit def pairingTube = RawPairing.tube
|
private implicit def pairingTube = RawPairing.tube
|
||||||
|
private implicit def eventTube = RawEvent.tube
|
||||||
private implicit def clockTube = TournamentClock.tube
|
private implicit def clockTube = TournamentClock.tube
|
||||||
private implicit def scheduleTube = Schedule.tube
|
private implicit def scheduleTube = Schedule.tube
|
||||||
private implicit def PlayerTube = Player.tube
|
private implicit def PlayerTube = Player.tube
|
||||||
|
@ -440,6 +484,8 @@ private[tournament] object RawTournament {
|
||||||
"startedAt" -> none[DateTime],
|
"startedAt" -> none[DateTime],
|
||||||
"players" -> List[Player](),
|
"players" -> List[Player](),
|
||||||
"pairings" -> List[RawPairing](),
|
"pairings" -> List[RawPairing](),
|
||||||
|
"events" -> List[RawEvent](),
|
||||||
|
"system" -> System.default.id,
|
||||||
"variant" -> Variant.Standard.id,
|
"variant" -> Variant.Standard.id,
|
||||||
"mode" -> Mode.Casual.id,
|
"mode" -> Mode.Casual.id,
|
||||||
"schedule" -> none[Schedule])
|
"schedule" -> none[Schedule])
|
||||||
|
|
|
@ -32,12 +32,14 @@ private[tournament] final class TournamentApi(
|
||||||
lobby: ActorSelection,
|
lobby: ActorSelection,
|
||||||
roundMap: ActorRef) {
|
roundMap: ActorRef) {
|
||||||
|
|
||||||
def makePairings(oldTour: Started, pairings: NonEmptyList[Pairing]) {
|
def makePairings(oldTour: Started, pairings: NonEmptyList[Pairing], postEvents: Events) {
|
||||||
sequence(oldTour.id) {
|
sequence(oldTour.id) {
|
||||||
TournamentRepo startedById oldTour.id flatMap {
|
TournamentRepo startedById oldTour.id flatMap {
|
||||||
case Some(tour) =>
|
case Some(tour) =>
|
||||||
val tour2 = tour addPairings pairings
|
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 =>
|
_.list foreach { game =>
|
||||||
game.tournamentId foreach { tid =>
|
game.tournamentId foreach { tid =>
|
||||||
sendTo(tid, StartGame(game))
|
sendTo(tid, StartGame(game))
|
||||||
|
@ -58,6 +60,7 @@ private[tournament] final class TournamentApi(
|
||||||
minPlayers = setup.minPlayers,
|
minPlayers = setup.minPlayers,
|
||||||
mode = Mode orDefault setup.mode,
|
mode = Mode orDefault setup.mode,
|
||||||
password = setup.password,
|
password = setup.password,
|
||||||
|
system = System orDefault setup.system,
|
||||||
variant = Variant orDefault setup.variant)
|
variant = Variant orDefault setup.variant)
|
||||||
$insert(created) >>-
|
$insert(created) >>-
|
||||||
(withdrawIds foreach socketReload) >>-
|
(withdrawIds foreach socketReload) >>-
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,31 +1,41 @@
|
||||||
package lila.tournament
|
package lila.tournament
|
||||||
|
package arena
|
||||||
|
|
||||||
case class Score(
|
import lila.tournament.{ ScoringSystem => AbstractScoringSystem }
|
||||||
win: Option[Boolean],
|
import lila.tournament.{ Score => AbstractScore }
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
object ScoringSystem extends AbstractScoringSystem {
|
||||||
sealed trait Flag
|
sealed trait Flag
|
||||||
case object StreakStarter extends Flag
|
case object StreakStarter extends Flag
|
||||||
case object Double extends Flag
|
case object Double extends Flag
|
||||||
case object Normal 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 filtered = tour userPairings user filter (_.finished) reverse
|
||||||
val nexts = (filtered drop 1 map Some.apply) :+ None
|
val nexts = (filtered drop 1 map Some.apply) :+ None
|
||||||
filtered.zip(nexts).foldLeft(List[Score]()) {
|
filtered.zip(nexts).foldLeft(List[Score]()) {
|
|
@ -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 Pairings = List[tournament.Pairing]
|
||||||
|
|
||||||
private[tournament] type Players = List[tournament.Player]
|
private[tournament] type Events = List[tournament.Event]
|
||||||
|
|
||||||
private[tournament] object RandomName {
|
private[tournament] object RandomName {
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -40,7 +40,8 @@ object ApplicationBuild extends Build {
|
||||||
gameSearch, timeline, forum, forumSearch, team, teamSearch,
|
gameSearch, timeline, forum, forumSearch, team, teamSearch,
|
||||||
ai, analyse, mod, monitor, site, round, lobby, setup,
|
ai, analyse, mod, monitor, site, round, lobby, setup,
|
||||||
importer, tournament, pool, relation, report, pref, // simulation,
|
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 moduleRefs = modules map projectToRef
|
||||||
lazy val moduleCPDeps = moduleRefs map { new sbt.ClasspathDependency(_, None) }
|
lazy val moduleCPDeps = moduleRefs map { new sbt.ClasspathDependency(_, None) }
|
||||||
|
@ -155,7 +156,7 @@ object ApplicationBuild extends Build {
|
||||||
)
|
)
|
||||||
|
|
||||||
lazy val setup = project("setup", Seq(
|
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)
|
libraryDependencies ++= provided(play.api, RM, PRM)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -164,7 +165,7 @@ object ApplicationBuild extends Build {
|
||||||
)
|
)
|
||||||
|
|
||||||
lazy val tournament = project("tournament", Seq(
|
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)
|
libraryDependencies ++= provided(play.api, RM, PRM)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -255,4 +256,6 @@ object ApplicationBuild extends Build {
|
||||||
)
|
)
|
||||||
|
|
||||||
lazy val chess = project("chess")
|
lazy val chess = project("chess")
|
||||||
|
|
||||||
|
lazy val swisssystem = project("swisssystem")
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue