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
Philippe Suter 2014-07-04 11:01:06 -04:00
parent 7638923d1c
commit f09a2e9893
28 changed files with 637 additions and 129 deletions

3
.gitmodules vendored
View File

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

View File

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

View File

@ -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"""<a data-icon="g" class="$cssClass" href="$url">&nbsp;$name</a>"""
}
def systemName(sys: System)(implicit ctx: UserContext) = sys match {
case System.Arena => trans.arena.str()
case System.Swiss => trans.swiss.str()
}
}

View File

@ -1,6 +1,7 @@
@(pov: Pov, tour: Option[lila.tournament.Tournament], withTourStanding: Boolean)(implicit ctx: Context)
@import pov._
@import lila.tournament.arena
<div class="goodies">
<div class="box">
@ -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) => {
<tr @if(ctx.userId.exists(player.id==)) { class="me" }>
<td class="name">
@if(player.withdraw) {
@ -87,7 +91,7 @@
@userInfosLink(player.id, none, withOnline = false)
</td>
<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>
</tr>
<tr><td class="around-bar" colspan="3"><div class="bar" data-value="@scoreSheet.total"></div></td></tr>

View File

@ -1,5 +1,7 @@
@(tour: lila.tournament.StartedOrFinished)(implicit ctx: Context)
@import lila.tournament.arena.ScoringSystem
<div class="standing_wrap scroll-shadow-soft">
<table class="slist standing @if(tour.scheduled) { scheduled }">
<thead>
@ -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) => {
<tr @if(ctx.userId.exists(player.id==)) { class="me" }>

View File

@ -31,6 +31,10 @@ moreJs = moreJs) {
<p class="error">@error.message</p>
}
<table>
<tr>
<th><label for="@form("system").id">@trans.system()</label></th>
<td>@base.select(form("system"), translatedSystemChoices)</td>
</tr>
<tr>
<th><label for="@form("variant").id">@trans.variant()</label></th>
<td>@base.select(form("variant"), translatedVariantChoices)</td>

View File

@ -24,6 +24,8 @@
@variantName(tour.variant).capitalize,
@{ tour.rated.fold(trans.rated(), trans.casual()) }
<br /><br />
@trans.system(): @systemName(tour.system).capitalize
<br /><br />
@trans.duration(): @tour.minutes minutes
@if(tour.isRunning) {
<br /><br />

View File

@ -4,6 +4,14 @@
<h1 data-icon="g">&nbsp;@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)

View File

@ -13,6 +13,13 @@
</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)

View File

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

View File

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

View File

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

View File

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

View File

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

@ -0,0 +1 @@
Subproject commit a4168747954cb70f4bb34a45af0b72c5ccad9a9c

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]()) {

View File

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

View File

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

View File

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