502 lines
14 KiB
Scala
502 lines
14 KiB
Scala
package lila.tournament
|
|
|
|
import com.github.nscala_time.time.Imports._
|
|
import org.joda.time.{ DateTime, Duration }
|
|
import ornicar.scalalib.Random
|
|
|
|
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,
|
|
variant: Variant,
|
|
mode: Mode,
|
|
password: Option[String],
|
|
schedule: Option[Schedule],
|
|
createdAt: DateTime,
|
|
createdBy: String)
|
|
|
|
sealed trait Tournament {
|
|
|
|
val id: String
|
|
val data: Data
|
|
def encode: RawTournament
|
|
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
|
|
|
|
def name = data.name
|
|
|
|
def clock = data.clock
|
|
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
|
|
def password = data.password
|
|
def hasPassword = password.isDefined
|
|
def schedule = data.schedule
|
|
def scheduled = data.schedule.isDefined
|
|
|
|
def userIds = players map (_.id)
|
|
def activePlayers = players filter (_.active)
|
|
def activeUserIds = activePlayers map (_.id)
|
|
def nbActiveUsers = players count (_.active)
|
|
def withdrawnPlayers = players filterNot (_.active)
|
|
def nbPlayers = players.size
|
|
def minPlayers = data.minPlayers
|
|
def playerRatio = if (scheduled) nbPlayers.toString else s"$nbPlayers/$minPlayers"
|
|
def durationString =
|
|
if (minutes < 60) s"${minutes}m"
|
|
else s"${minutes / 60}h" + (if (minutes % 60 != 0) s" ${(minutes % 60)}m" else "")
|
|
def contains(userId: String): Boolean = userIds contains userId
|
|
def contains(user: User): Boolean = contains(user.id)
|
|
def contains(user: Option[User]): Boolean = ~user.map(contains)
|
|
def isActive(userId: String): Boolean = activeUserIds contains userId
|
|
def isActive(user: User): Boolean = isActive(user.id)
|
|
def isActive(user: Option[User]): Boolean = ~user.map(isActive)
|
|
def missingPlayers = minPlayers - players.size
|
|
|
|
def createdBy = data.createdBy
|
|
def createdAt = data.createdAt
|
|
|
|
def isCreator(userId: String) = data.createdBy == userId
|
|
|
|
def userPairings(user: String) = pairings filter (_ contains user)
|
|
|
|
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 {
|
|
|
|
def withPlayers(s: Players): Enterable
|
|
|
|
def join(user: User, pass: Option[String]): Valid[Enterable]
|
|
|
|
def withdraw(userId: String): Valid[Enterable]
|
|
|
|
def joinNew(user: User, pass: Option[String]): Valid[Enterable] = contains(user).fold(
|
|
!!("User %s is already part of the tournament" format user.id),
|
|
(pass != password).fold(
|
|
!!("Invalid tournament password"),
|
|
withPlayers(players :+ Player.make(user)).success
|
|
))
|
|
|
|
def ejectCheater(userId: String): Option[Enterable] =
|
|
activePlayers.find(_.id == userId) map { player =>
|
|
withPlayers(players map {
|
|
case p if p is player => p.doWithdraw
|
|
case p => p
|
|
})
|
|
}
|
|
}
|
|
|
|
sealed trait StartedOrFinished extends Tournament {
|
|
type RankedPlayers = List[(Int,Player)]
|
|
|
|
def startedAt: DateTime
|
|
def withPlayers(s: Players): StartedOrFinished
|
|
def refreshPlayers: StartedOrFinished
|
|
|
|
def rankedPlayers: RankedPlayers = system.scoringSystem.rank(this, players)
|
|
|
|
def winner = players.headOption
|
|
def winnerUserId = winner map (_.id)
|
|
|
|
def playingPairings = pairings filter (_.playing)
|
|
def recentGameIds(max: Int) = pairings take max map (_.gameId)
|
|
|
|
def encode(status: Status) = new RawTournament(
|
|
id = id,
|
|
status = status.id,
|
|
name = data.name,
|
|
system = data.system.id,
|
|
clock = data.clock,
|
|
minutes = data.minutes,
|
|
minPlayers = data.minPlayers,
|
|
variant = data.variant.id,
|
|
mode = data.mode.id,
|
|
password = data.password,
|
|
createdAt = data.createdAt,
|
|
createdBy = data.createdBy,
|
|
startedAt = startedAt.some,
|
|
schedule = data.schedule,
|
|
players = players,
|
|
pairings = pairings map (_.encode),
|
|
events = events map (_.encode))
|
|
|
|
def finishedAt = startedAt + duration
|
|
}
|
|
|
|
case class Created(
|
|
id: String,
|
|
data: Data,
|
|
players: Players) extends Tournament with Enterable {
|
|
|
|
import data._
|
|
|
|
override def isOpen = true
|
|
|
|
def enoughPlayersToStart = !scheduled && nbPlayers >= minPlayers
|
|
|
|
def enoughPlayersToEarlyStart = !scheduled && nbPlayers >= math.min(minPlayers, Tournament.minPlayers)
|
|
|
|
def isEmpty = players.isEmpty
|
|
|
|
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,
|
|
password = data.password,
|
|
minutes = data.minutes,
|
|
minPlayers = data.minPlayers,
|
|
schedule = data.schedule,
|
|
createdAt = data.createdAt,
|
|
createdBy = data.createdBy,
|
|
players = players)
|
|
|
|
def withdraw(userId: String): Valid[Created] = contains(userId).fold(
|
|
withPlayers(players filterNot (_ is userId)).success,
|
|
!!("User %s is not part of the tournament" format userId)
|
|
)
|
|
|
|
def withPlayers(s: Players) = copy(players = s)
|
|
|
|
def startIfReady = enoughPlayersToStart option start
|
|
|
|
def start = Started(id, data, DateTime.now, players, Nil, Nil)
|
|
|
|
def asScheduled = schedule map { Scheduled(this, _) }
|
|
|
|
def join(user: User, pass: Option[String]) = joinNew(user, pass)
|
|
}
|
|
|
|
case class Scheduled(tour: Created, schedule: Schedule) {
|
|
|
|
def endsAt = schedule.at plus (tour.minutes.toLong * 60 * 1000)
|
|
def interval = new org.joda.time.Interval(schedule.at, endsAt)
|
|
def overlaps(other: Scheduled) = interval overlaps other.interval
|
|
}
|
|
|
|
case class EnterableTournaments(tours: List[Created], scheduled: List[Created])
|
|
|
|
case class Started(
|
|
id: String,
|
|
data: Data,
|
|
startedAt: DateTime,
|
|
players: Players,
|
|
pairings: List[Pairing],
|
|
events: List[Event]) extends StartedOrFinished with Enterable {
|
|
|
|
override def isRunning = true
|
|
|
|
def addPairings(ps: scalaz.NonEmptyList[Pairing]) =
|
|
copy(pairings = ps.list ::: pairings)
|
|
|
|
def updatePairing(gameId: String, f: Pairing => Pairing) = copy(
|
|
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,
|
|
((finishedAt.getMillis - nowMillis) / 1000).toFloat
|
|
)
|
|
|
|
def isAlmostFinished = remainingSeconds < math.max(60, math.min(clock.limit / 2, 120))
|
|
|
|
def clockStatus = remainingSeconds.toInt |> { s =>
|
|
"%02d:%02d".format(s / 60, s % 60)
|
|
}
|
|
|
|
def userCurrentPov(userId: String): Option[PovRef] =
|
|
playingPairings.flatMap(_ povRef userId).headOption
|
|
|
|
def userCurrentPov(user: Option[User]): Option[PovRef] =
|
|
user.flatMap(u => userCurrentPov(u.id))
|
|
|
|
def leaders: List[Player] = rankedPlayers filter {
|
|
case (rank, player) => rank <= 2 && player.score >= 8
|
|
} map (_._2)
|
|
|
|
def finish = refreshPlayers |> { tour =>
|
|
Finished(
|
|
id = tour.id,
|
|
data = tour.data,
|
|
startedAt = tour.startedAt,
|
|
players = tour.players,
|
|
pairings = tour.pairings filterNot (_.playing),
|
|
events = tour.events)
|
|
}
|
|
|
|
def withdraw(userId: String): Valid[Started] = contains(userId).fold(
|
|
withPlayers(players map {
|
|
case p if p is userId => p.doWithdraw
|
|
case p => p
|
|
}).success,
|
|
!!("User %s is not part of the tournament" format userId)
|
|
)
|
|
|
|
def quickLossStreak(user: String): Boolean =
|
|
userPairings(user).takeWhile { pair => (pair lostBy user) && pair.quickLoss }.size >= 3
|
|
|
|
def withPlayers(s: Players) = copy(players = s)
|
|
def refreshPlayers = withPlayers(Player refresh this)
|
|
|
|
def encode = refreshPlayers.encode(Status.Started)
|
|
|
|
def join(user: User, pass: Option[String]) = joinNew(user, pass) orElse joinBack(user, pass)
|
|
|
|
private def joinBack(user: User, pass: Option[String]) = withdrawnPlayers.find(_ is user) match {
|
|
case None => !!("User %s is already part of the tournament" format user.id)
|
|
case Some(player) => (pass != password).fold(
|
|
!!("Invalid tournament password"),
|
|
withPlayers(players map {
|
|
case p if p is player => p.unWithdraw
|
|
case p => p
|
|
}).success)
|
|
}
|
|
}
|
|
|
|
case class Finished(
|
|
id: String,
|
|
data: Data,
|
|
startedAt: DateTime,
|
|
players: Players,
|
|
pairings: List[Pairing],
|
|
events: List[Event]) extends StartedOrFinished {
|
|
|
|
override def isFinished = true
|
|
|
|
def withPlayers(s: Players) = copy(players = s)
|
|
def refreshPlayers = withPlayers(Player refresh this)
|
|
|
|
def encode = encode(Status.Finished)
|
|
}
|
|
|
|
object Tournament {
|
|
|
|
val minPlayers = 4
|
|
|
|
import lila.db.JsTube
|
|
import play.api.libs.json._
|
|
|
|
private def reader[T <: Tournament](decode: RawTournament => Option[T])(js: JsValue): JsResult[T] = ~(for {
|
|
obj ← js.asOpt[JsObject]
|
|
rawTour ← RawTournament.tube.read(obj).asOpt
|
|
tour ← decode(rawTour)
|
|
} yield JsSuccess(tour): JsResult[T])
|
|
|
|
private lazy val writer = Writes[Tournament](tour =>
|
|
RawTournament.tube.write(tour.encode) getOrElse JsUndefined("[db] Can't write tournament " + tour.id)
|
|
)
|
|
|
|
private[tournament] val tube: JsTube[Tournament] =
|
|
JsTube(Reads(reader(_.decode)), writer)
|
|
|
|
private[tournament] val enterableTube: JsTube[Enterable] =
|
|
JsTube(Reads(reader(_.enterable)), writer)
|
|
|
|
private[tournament] val createdTube: JsTube[Created] =
|
|
JsTube(Reads(reader(_.created)), writer)
|
|
|
|
private[tournament] val startedTube: JsTube[Started] =
|
|
JsTube(Reads(reader(_.started)), writer)
|
|
|
|
private[tournament] val finishedTube: JsTube[Finished] =
|
|
JsTube(Reads(reader(_.finished)), writer)
|
|
|
|
def make(
|
|
createdBy: User,
|
|
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,
|
|
variant = variant,
|
|
mode = mode,
|
|
password = password,
|
|
minutes = minutes,
|
|
schedule = None,
|
|
minPlayers = minPlayers),
|
|
players = List(Player make createdBy))
|
|
|
|
def schedule(sched: Schedule, minutes: Int) = Created(
|
|
id = Random nextStringUppercase 8,
|
|
data = Data(
|
|
name = sched.name,
|
|
system = System.default,
|
|
clock = Schedule clockFor sched,
|
|
createdBy = "lichess",
|
|
createdAt = DateTime.now,
|
|
variant = Variant.Standard,
|
|
mode = Mode.Rated,
|
|
password = None,
|
|
minutes = minutes,
|
|
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,
|
|
password: Option[String] = None,
|
|
createdAt: DateTime,
|
|
createdBy: String,
|
|
status: Int,
|
|
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) {
|
|
|
|
def decode: Option[Tournament] = created orElse started orElse finished
|
|
|
|
def created: Option[Created] = (status == Status.Created.id) option Created(
|
|
id = id,
|
|
data = data,
|
|
players = players)
|
|
|
|
def started: Option[Started] = for {
|
|
stAt ← startedAt
|
|
if status == Status.Started.id
|
|
} yield Started(
|
|
id = id,
|
|
data = data,
|
|
startedAt = stAt,
|
|
players = players,
|
|
pairings = decodePairings,
|
|
events = decodeEvents)
|
|
|
|
def finished: Option[Finished] = for {
|
|
stAt ← startedAt
|
|
if status == Status.Finished.id
|
|
} yield Finished(
|
|
id = id,
|
|
data = data,
|
|
startedAt = stAt,
|
|
players = players,
|
|
pairings = decodePairings,
|
|
events = decodeEvents)
|
|
|
|
def enterable: Option[Enterable] = created orElse started
|
|
|
|
private def data = Data(
|
|
name,
|
|
System orDefault system,
|
|
clock,
|
|
minutes,
|
|
minPlayers,
|
|
Variant orDefault variant,
|
|
Mode orDefault mode,
|
|
password,
|
|
schedule,
|
|
createdAt,
|
|
createdBy)
|
|
|
|
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
|
|
case Status.Finished => finished
|
|
}
|
|
}
|
|
|
|
private[tournament] object RawTournament {
|
|
|
|
import lila.db.JsTube
|
|
import JsTube.Helpers._
|
|
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
|
|
|
|
private def defaults = Json.obj(
|
|
"password" -> none[String],
|
|
"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])
|
|
|
|
private[tournament] val tube = JsTube(
|
|
(__.json update (
|
|
merge(defaults) andThen readDate('createdAt) andThen readDateOpt('startedAt)
|
|
)) andThen Json.reads[RawTournament],
|
|
Json.writes[RawTournament] andThen (__.json update (
|
|
writeDate('createdAt) andThen writeDateOpt('startedAt)
|
|
))
|
|
)
|
|
}
|