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"] [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

View File

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

View File

@ -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">&nbsp;$name</a>""" 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) @(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>

View File

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

View File

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

View File

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

View File

@ -4,6 +4,14 @@
<h1 data-icon="g">&nbsp;@if(tour.scheduled){@tour.name} else {@trans.xTournament(tour.name)}</h1> <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) @tournament.games(games)

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

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

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