diff --git a/app/controllers/Tournament.scala b/app/controllers/Tournament.scala index d5c8a14ff0..1310abee7c 100644 --- a/app/controllers/Tournament.scala +++ b/app/controllers/Tournament.scala @@ -2,7 +2,7 @@ package controllers import lila._ import views._ -import tournament.{ Created } +import tournament.{ Created, Started } import http.Context import scalaz.effects._ @@ -21,30 +21,49 @@ object Tournament extends LilaController { private def userRepo = env.user.userRepo val home = Open { implicit ctx ⇒ - IOk(repo.created map { tournaments ⇒ - html.tournament.home(tournaments) - }) + IOk( + for { + createds ← repo.created + starteds ← repo.started + } yield html.tournament.home(createds, starteds) + ) } def show(id: String) = Open { implicit ctx ⇒ IOptionIOk(repo byId id) { - case tour: Created ⇒ for { - roomHtml ← messenger render tour - users ← userRepo byIds tour.data.users - } yield html.tournament.show.created( - tour = tour, - roomHtml = Html(roomHtml), - version = version(tour.id), - users = users - ) - case _ ⇒ throw new Exception("oups") + case tour: Created ⇒ showCreated(tour) + case tour: Started ⇒ showStarted(tour) + case _ ⇒ throw new Exception("uuuh") } } + private def showCreated(tour: Created)(implicit ctx: Context) = for { + roomHtml ← messenger render tour + users ← userRepo byIds tour.data.users + } yield html.tournament.show.created( + tour = tour, + roomHtml = Html(roomHtml), + version = version(tour.id), + users = users) + + private def showStarted(tour: Started)(implicit ctx: Context) = for { + roomHtml ← messenger render tour + users ← userRepo byIds tour.data.users + } yield html.tournament.show.started( + tour = tour, + roomHtml = Html(roomHtml), + version = version(tour.id), + users = users) + def join(id: String) = Auth { implicit ctx ⇒ implicit me ⇒ IOptionIORedirect(repo createdById id) { tour ⇒ - api.join(tour, me) map { _ ⇒ routes.Tournament.show(tour.id) } + api.join(tour, me) map { result ⇒ + result.fold( + err ⇒ { println(err.shows); routes.Tournament.home() }, + _ ⇒ routes.Tournament.show(tour.id) + ) + } } } @@ -75,7 +94,10 @@ object Tournament extends LilaController { forms.create.bindFromRequest.fold( err ⇒ io(BadRequest(html.message.form(err))), setup ⇒ api.createTournament(setup, me).map(tournament ⇒ - Redirect(routes.Tournament.show(tournament.id)) + tournament.fold( + err ⇒ { println(err.shows); Redirect(routes.Tournament.home()) }, + tour ⇒ Redirect(routes.Tournament.show(tour.id)) + ) )) } } diff --git a/app/core/Cron.scala b/app/core/Cron.scala index 286f75a390..809937fc27 100644 --- a/app/core/Cron.scala +++ b/app/core/Cron.scala @@ -86,12 +86,12 @@ object Cron { // also sent when the last player joins message(8 seconds) { - env.tournament.organizer -> tournament.StartTournament + env.tournament.organizer -> tournament.StartTournaments } // also sent when a player completes a game message(5 seconds) { - env.tournament.organizer -> tournament.StartPairing + env.tournament.organizer -> tournament.StartPairings } def message(freq: Duration)(to: (ActorRef, Any)) { diff --git a/app/package.scala b/app/package.scala index 0eb828770c..75d60f2799 100644 --- a/app/package.scala +++ b/app/package.scala @@ -34,7 +34,7 @@ package object lila } RegisterJodaTimeConversionHelpers() - def !![A](msg: String) = msg.failNel[A] + def !![A](msg: String): Valid[A] = msg.failNel[A] def nowMillis: Double = System.currentTimeMillis def nowSeconds: Int = (nowMillis / 1000).toInt diff --git a/app/tournament/Organizer.scala b/app/tournament/Organizer.scala index ba970da7fc..0509db05b3 100644 --- a/app/tournament/Organizer.scala +++ b/app/tournament/Organizer.scala @@ -20,22 +20,24 @@ final class Organizer( def receive = { - case StartTournament ⇒ startTournament.unsafePerformIO + case StartTournaments ⇒ startTournaments.unsafePerformIO + case StartTournament(tour: Created) ⇒ api.start(tour).unsafePerformIO - case StartPairing ⇒ startPairing.unsafePerformIO + case StartPairings ⇒ startPairings.unsafePerformIO + case StartPairing(tour: Started) ⇒ startPairing(tour).unsafePerformIO } - def startTournament = for { - tours ← repo.created - } yield (tours filter (_.readyToStart) map api.start).sequence + def startTournaments = repo.created flatMap { created => + (created map api.start).sequence + } - def startPairing = for { - tours ← repo.started - } yield (for { - tour ← tours - } yield Pairer(tour).toNel.fold( - pairings ⇒ api.makePairings(tour, pairings), - io() - )).toList.sequence + def startPairings = repo.started flatMap { started => + (started map startPairing).sequence + } + def startPairing(tour: Started) = + Pairing.createNewPairings(tour.users, tour.pairings).toNel.fold( + pairings ⇒ api.makePairings(tour, pairings), + io() + ) } diff --git a/app/tournament/Pairer.scala b/app/tournament/Pairer.scala deleted file mode 100644 index beb32b09ae..0000000000 --- a/app/tournament/Pairer.scala +++ /dev/null @@ -1,14 +0,0 @@ -package lila -package tournament - -object Pairer { - - def apply(tour: Started): List[Pairing] = - tour.pairings.fold(List[Pairing]()) { - case (existing, pairings) if existing.status >= chess.Status.Mate => - - case _ => pairings - } - - private def idlePlayers(pairings: List[Pairings]) -} diff --git a/app/tournament/Pairing.scala b/app/tournament/Pairing.scala new file mode 100644 index 0000000000..7bba0e10f0 --- /dev/null +++ b/app/tournament/Pairing.scala @@ -0,0 +1,52 @@ +package lila +package tournament + +import game.IdGenerator + +case class Pairing( + gameId: String, + status: chess.Status, + user1: String, + user2: String) { + + def encode: RawPairing = RawPairing(gameId, status.id, users) + + def users = List(user1, user2) + def usersPair = user1 -> user2 + def contains(user: String) = user1 == user || user2 == user + + def finished = status >= chess.Status.Mate + def playing = !finished + + def withStatus(s: chess.Status) = copy(status = s) +} + +case class RawPairing(g: String, s: Int, u: List[String]) { + + def decode: Option[Pairing] = for { + status ← chess.Status(s) + user1 ← u.lift(0) + user2 ← u.lift(1) + } yield Pairing(g, status, user1, user2) +} + +object Pairing { + + def apply(user1: String, user2: String): Pairing = new Pairing( + gameId = IdGenerator.game, + status = chess.Status.Created, + user1 = user1, + user2 = user2) + + def createNewPairings(users: List[String], pairings: Pairings): Pairings = { + val idleUsers: List[String] = pairings.filter(_.playing).foldLeft(users) { + (idles, pairing) ⇒ idles filterNot pairing.contains + }.toList + naivePairing(idleUsers).toList + } + + private def naivePairing(users: List[String]) = users grouped 2 collect { + case List(u1, u2) ⇒ Pairing(u1, u2) + } + +} diff --git a/app/tournament/Tournament.scala b/app/tournament/Tournament.scala index ea4a2bc0ae..0c628dfcef 100644 --- a/app/tournament/Tournament.scala +++ b/app/tournament/Tournament.scala @@ -5,6 +5,7 @@ import org.joda.time.{ DateTime, Duration } import org.scala_tools.time.Imports._ import com.novus.salat.annotations.Key import ornicar.scalalib.OrnicarRandom +import scalaz.NonEmptyList import user.User @@ -21,16 +22,18 @@ sealed trait Tournament { val data: Data def encode: RawTournament - import data._ - + def minutes = data.minutes lazy val duration = new Duration(minutes * 60 * 1000) - def missingUsers = minUsers - users.size - + def users = data.users + def nbUsers = users.size + def minUsers = data.minUsers def contains(username: String): Boolean = users contains username def contains(user: User): Boolean = contains(user.id) + def missingUsers = minUsers - users.size def showClock = "2 + 0" + def createdBy = data.createdBy } case class Created( @@ -67,32 +70,17 @@ case class Started( startedAt: DateTime, pairings: List[Pairing]) extends Tournament { + def addPairings(ps: NonEmptyList[Pairing]) = + copy(pairings = pairings ::: ps.list) + def encode = new RawTournament( id = id, - status = Status.Created.id, + status = Status.Started.id, data = data, startedAt = startedAt.some, pairings = pairings map (_.encode)) } -case class Pairing( - gameId: String, - status: chess.Status, - users: List[String]) { - - def encode: RawPairing = RawPairing(gameId, status.id, users) -} - -case class RawPairing( - g: String, - s: Int, - u: List[String]) { - - def decode: Option[Pairing] = chess.Status(s) map { status ⇒ - Pairing(g, status, u) - } -} - case class RawTournament( @Key("_id") id: String, status: Int, @@ -106,7 +94,7 @@ case class RawTournament( def started: Option[Started] = for { stAt ← startedAt - if status == Status.Created.id + if status == Status.Started.id } yield Started( id = id, data = data, @@ -115,7 +103,11 @@ case class RawTournament( def decodePairings = pairings map (_.decode) flatten - def any: Option[Tournament] = created + def any: Option[Tournament] = Status(status) flatMap { + case Status.Created ⇒ created + case Status.Started ⇒ started + case _ ⇒ None + } } object Tournament { @@ -141,5 +133,5 @@ object Tournament { val minUsers = (2 to 4) ++ (5 to 30 by 5) val minUserDefault = 10 - val minUserChoices = options(minUsers, "%d players{s}") + val minUserChoices = options(minUsers, "%d player{s}") } diff --git a/app/tournament/TournamentApi.scala b/app/tournament/TournamentApi.scala index e574fa9a10..df8b7461a7 100644 --- a/app/tournament/TournamentApi.scala +++ b/app/tournament/TournamentApi.scala @@ -12,28 +12,45 @@ final class TournamentApi( repo: TournamentRepo, socket: Socket) { - def makePairings(tour: Started, pairings: NonEmptyList[Pairing]): IO[Unit] = { - io() - } + def makePairings(tour: Started, pairings: NonEmptyList[Pairing]): IO[Unit] = + (tour addPairings pairings) |> { tour2 ⇒ + for { + _ ← repo saveIO tour2 + _ ← (pairings map { pairing ⇒ + io() // create the game + }).sequence + } yield () + } - def createTournament(setup: TournamentSetup, me: User): IO[Created] = - Tournament( - createdBy = me.id, - minutes = setup.minutes, - minUsers = setup.minUsers - ) |> { created ⇒ - repo saveIO created map (_ ⇒ created) - } + def createTournament(setup: TournamentSetup, me: User): IO[Valid[Created]] = for { + hasTournament ← repo userHasRunningTournament me.id + tournament ← hasTournament.fold( + io(alreadyInATournament(me)), + Tournament( + createdBy = me.id, + minutes = setup.minutes, + minUsers = setup.minUsers + ) |> { created ⇒ repo saveIO created map (_ ⇒ created.success) } + ) + } yield tournament - def start(created: Created): IO[Unit] = repo saveIO created.start + def start(created: Created): IO[Unit] = + repo saveIO created.start doIf created.readyToStart - def join(tour: Created, me: User): IO[Unit] = (tour join me).fold( - err ⇒ putStrLn(err.shows), - tour2 ⇒ for { - _ ← repo saveIO tour2 - _ ← io(socket reloadUserList tour.id) - } yield () - ) + def join(tour: Created, me: User): IO[Valid[Unit]] = for { + hasTournament ← repo userHasRunningTournament me.id + tour2Valid = for { + tour2 ← tour join me + _ ← hasTournament.fold(alreadyInATournament(me), true.success) + } yield tour2 + result ← tour2Valid.fold( + err ⇒ io(failure(err)), + tour2 ⇒ for { + _ ← repo saveIO tour2 + _ ← io(socket reloadUserList tour.id) + } yield ().success + ): IO[Valid[Unit]] + } yield result def withdraw(tour: Created, me: User): IO[Unit] = (tour withdraw me).fold( err ⇒ putStrLn(err.shows), @@ -42,4 +59,7 @@ final class TournamentApi( _ ← io(socket reloadUserList tour.id) } yield () ) + + private def alreadyInATournament[A](user: User) = + !![A]("%s already has a tournament in progress" format user.username) } diff --git a/app/tournament/TournamentRepo.scala b/app/tournament/TournamentRepo.scala index 96ddee6f37..e38e6807d7 100644 --- a/app/tournament/TournamentRepo.scala +++ b/app/tournament/TournamentRepo.scala @@ -36,6 +36,12 @@ class TournamentRepo(collection: MongoCollection) update(idSelector(tourId), $set("data.users" -> userIds.distinct)) } + def userHasRunningTournament(username: String): IO[Boolean] = io { + collection.findOne( + ("status" $ne Status.Finished.id) ++ ("data.users" -> username) + ).isDefined + } + def saveIO(tournament: Tournament): IO[Unit] = io { save(tournament.encode) } diff --git a/app/tournament/actorApi.scala b/app/tournament/actorApi.scala index 3be5bd7746..fdfc6a00f6 100644 --- a/app/tournament/actorApi.scala +++ b/app/tournament/actorApi.scala @@ -39,5 +39,7 @@ case object HubTimeout case object GetNbHubs // organizer -case object StartTournament -case object StartPairing +case object StartTournaments +case class StartTournament(tour: Created) +case object StartPairings +case class StartPairing(tour: Started) diff --git a/app/views/tournament/home.scala.html b/app/views/tournament/home.scala.html index 492c5fd6ab..eec53668e7 100644 --- a/app/views/tournament/home.scala.html +++ b/app/views/tournament/home.scala.html @@ -1,4 +1,4 @@ -@(tournaments: List[lila.tournament.Tournament])(implicit ctx: Context) +@(createds: List[lila.tournament.Created], starteds: List[lila.tournament.Started])(implicit ctx: Context) @tournament.layout( title = "Tournaments") { @@ -7,9 +7,62 @@ title = "Tournaments") {

Tournaments

- list of tournaments + + + + + + + + @createds.map { tournament => + + + + + + + + } + +
Incoming tournaments
+ + @userIdToUsername(tournament.createdBy)'s tournament + + @tournament.showClock@tournament.minutes minutes@tournament.nbUsers / @tournament.minUsers players + @if(ctx.me.fold(tournament.contains, false)) { + JOINED + } +
+ + + + + + + + @starteds.map { tournament => + + + + + + + + } + +
In progress
+ + @userIdToUsername(tournament.createdBy)'s tournament + + @tournament.showClock@tournament.minutes minutes@tournament.nbUsers / @tournament.minUsers players + @if(ctx.me.fold(tournament.contains, false)) { + JOINED + } +
- Create a new tournament +
+ Create a new tournament +
} diff --git a/app/views/tournament/show/created.scala.html b/app/views/tournament/show/created.scala.html index 8aefe21950..519a46699d 100644 --- a/app/views/tournament/show/created.scala.html +++ b/app/views/tournament/show/created.scala.html @@ -1,8 +1,6 @@ @(tour: lila.tournament.Created, roomHtml: Html, version: Int, users: List[User])(implicit ctx: Context) -@import tour._ - -@title = @{ tour.showClock + " tournament" } +@title = @{ tour.showClock + " tournament pending" } @tournament.show.layout( tour = tour, @@ -10,11 +8,11 @@ roomHtml = roomHtml, version = version, title = title) { -

@title pending

+

@title

- Waiting for @missingUsers players + Waiting for @tour.missingUsers players
-
+
@tournament.userList(users)
diff --git a/app/views/tournament/show/started.scala.html b/app/views/tournament/show/started.scala.html new file mode 100644 index 0000000000..265e2e4371 --- /dev/null +++ b/app/views/tournament/show/started.scala.html @@ -0,0 +1,23 @@ +@(tour: lila.tournament.Started, roomHtml: Html, version: Int, users: List[User])(implicit ctx: Context) + +@title = @{ tour.showClock + " tournament in progress" } + +@tournament.show.layout( +tour = tour, +roomHtml = roomHtml, +version = version, +title = title) { + +

@title

+
+ @tournament.userList(users) +
+ +@ctx.me.filter(tour.contains).map { me => +
+
+ +
+
+} +} diff --git a/test/PairingTest.scala b/test/PairingTest.scala new file mode 100644 index 0000000000..06e0ac9791 --- /dev/null +++ b/test/PairingTest.scala @@ -0,0 +1,76 @@ +package lila +package tournament + +import tournament._ + +class PairingTest extends LilaSpec { + + import Pairing._ + + val u1 = "u1" + val u2 = "u2" + val u3 = "u3" + val u4 = "u4" + val u5 = "u5" + + "Create new pairings" should { + "2 players" in { + val users = List(u1, u2) + "first pairing" in { + val pairings = List[Pairing]() + pairs(users, pairings) must_== List(u1 -> u2) + } + "finished pairing" in { + val pairings = List(mate(u1, u2)) + pairs(users, pairings) must_== List(u1 -> u2) + } + "started pairing" in { + val pairings = List(started(u1, u2)) + pairs(users, pairings) must_== Nil + } + "finished and started pairing" in { + val pairings = List(mate(u1, u2), started(u1, u2)) + pairs(users, pairings) must_== Nil + } + } + } + "3 players" should { + val users = List(u1, u2, u3) + "first pairing" in { + val pairings = List[Pairing]() + pairs(users, pairings) must beOneOf(List(u1 -> u2), List(u1 -> u3), List(u2 -> u3)) + } + "finished pairing" in { + val pairings = List(mate(u1, u2)) + pairs(users, pairings) must_== List(u1 -> u3) + } + "started pairing" in { + val pairings = List(started(u1, u2)) + pairs(users, pairings) must_== Nil + } + "finished and started pairing" in { + val pairings = List(mate(u1, u2), started(u1, u3)) + pairs(users, pairings) must_== Nil + } + "many finished pairings" in { + "without ambiguity" in { + val pairings = List(mate(u1, u2), mate(u1, u3)) + pairs(users, pairings) must_== List(u2 -> u3) + } + "favor longer idle" in { + val pairings = List(mate(u1, u2), mate(u1, u3), mate(u2, u3)) + pairs(users, pairings) must_== List(u1 -> u2) + } + } + } + + private def pairs(users: List[String], pairings: Pairings) = + createNewPairings(users, pairings) map (_.usersPair) map { + case (a, b) ⇒ (a < b).fold(a -> b, b -> a) + } + + private def started(a: String, b: String) = + Pairing(a, b) withStatus chess.Status.Started + private def mate(a: String, b: String) = + Pairing(a, b) withStatus chess.Status.Mate +}