new tournament scoring system and UI
This commit is contained in:
parent
db095f12c0
commit
8c9b127701
|
@ -46,7 +46,7 @@ object Tournament extends LilaController {
|
|||
repo byId id flatMap {
|
||||
_ match {
|
||||
case Some(tour: Created) => showCreated(tour) map { Ok(_) }
|
||||
case Some(tour: Started) => showStarted(tour.refreshPlayers) map { Ok(_) }
|
||||
case Some(tour: Started) => showStarted(tour) map { Ok(_) }
|
||||
case Some(tour: Finished) => showFinished(tour) map { Ok(_) }
|
||||
case _ => tournamentNotFound.fuccess
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ moreJs = moreJs,
|
|||
goodies = goodies,
|
||||
chat = chat,
|
||||
active = siteMenu.tournament.some,
|
||||
themepicker = true,
|
||||
underchat = underchat) {
|
||||
@body
|
||||
}
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
@(tour: lila.tournament.Tournament)(implicit ctx: Context)
|
||||
|
||||
@showUser(p: lila.tournament.Pairing, u: String) = {
|
||||
<span class="@p.finished.fold(p.draw.fold("draw", p.wonBy(u).fold("win", "loss")), "playing")">@usernameOrId(u)</span>
|
||||
}
|
||||
|
||||
<div class="pairings">
|
||||
@tour.numerotedPairings.map {
|
||||
case (number, pairing) => {
|
||||
<a class="revert-underline" href="@routes.Round.watcher(pairing.gameId, "white")">
|
||||
@number
|
||||
@{ Html(pairing.users.map(u =>
|
||||
"<span class='%s'>%s</span>".format(
|
||||
pairing.finished.fold(pairing.draw.fold("draw", pairing.wonBy(u).fold("win", "loss")), "playing"),
|
||||
usernameOrId(u)
|
||||
)).mkString(" vs "))
|
||||
}
|
||||
</a>
|
||||
<a class="revert-underline" href="@routes.Round.watcher(pairing.gameId, "white")">
|
||||
@number @showUser(pairing, pairing.user1) <em>vs</em> @showUser(pairing, pairing.user2)
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -1,44 +1,52 @@
|
|||
@(tour: lila.tournament.StartedOrFinished)(implicit ctx: Context)
|
||||
|
||||
<table class="slist standing @if(tour.scheduled) { scheduled }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="large" colspan="2">@trans.standing()</th>
|
||||
<th>@trans.points()</th>
|
||||
<th>@trans.wins()</th>
|
||||
<th>@trans.losses()</th>
|
||||
<th>@trans.winStreak()</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@tour.rankedPlayers.map {
|
||||
case (rank, player) => {
|
||||
@defining(if(tour.isFinished && rank == 1) "winner" else if (player.withdraw) "withdraw" else "") { flag =>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="rank">@rank</span>
|
||||
@if(tour.isFinished && rank == 1) {
|
||||
<span data-icon="g" title="@trans.winner()"></span>
|
||||
} else {
|
||||
@if(player.withdraw) {
|
||||
<span data-icon="b" title="@trans.withdraw()"></span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@userInfosLink(player.username, player.rating.some, withOnline = false)
|
||||
</td>
|
||||
<td><strong>@player.score</strong></td>
|
||||
<td>@player.nbWin</td>
|
||||
<td>@player.nbLoss</td>
|
||||
<td>
|
||||
@if(player.winStreak > 1) {
|
||||
@player.winStreak
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="standing_wrap">
|
||||
<table class="slist standing @if(tour.scheduled) { scheduled }">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="large">@trans.standing()</th>
|
||||
<th class="legend">
|
||||
<span class="streakstarter">Streak starter</span>
|
||||
<span class="double">Double points</span>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@tour.rankedPlayers.map {
|
||||
case (rank, player) => {
|
||||
@defining((
|
||||
if(tour.isFinished && rank == 1) "winner" else if (player.withdraw) "withdraw" else "",
|
||||
tour scoreSheet player
|
||||
)) {
|
||||
case (flag, scoreSheet) => {
|
||||
<tr>
|
||||
<td>
|
||||
@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.username, none, withOnline = false)
|
||||
</td>
|
||||
<td class="sheet">
|
||||
@scoreSheet.scores.take(20).reverse.map { score =>
|
||||
<span class="@score.flag.toString.toLowerCase">@score.value</span>
|
||||
}
|
||||
</td>
|
||||
<td class="total">
|
||||
<strong@if(scoreSheet.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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,7 @@ import scala.util.Random
|
|||
import chess.Color
|
||||
import lila.game.{ PovRef, IdGenerator }
|
||||
|
||||
private[tournament] case class Pairing(
|
||||
case class Pairing(
|
||||
gameId: String,
|
||||
status: chess.Status,
|
||||
user1: String,
|
||||
|
@ -25,6 +25,7 @@ private[tournament] case class Pairing(
|
|||
|
||||
def lostBy(user: String) = ~winner.map(user !=)
|
||||
def quickLoss = finished && ~turns.map(10 >)
|
||||
def quickDraw = draw && ~turns.map(10 >)
|
||||
|
||||
def opponentOf(user: String): Option[String] =
|
||||
if (user == user1) user2.some else if (user == user2) user1.some else none
|
||||
|
|
|
@ -7,9 +7,6 @@ private[tournament] case class Player(
|
|||
username: String,
|
||||
rating: Int,
|
||||
withdraw: Boolean = false,
|
||||
nbWin: Int = 0,
|
||||
nbLoss: Int = 0,
|
||||
winStreak: Int = 0,
|
||||
score: Int = 0) {
|
||||
|
||||
def active = !withdraw
|
||||
|
@ -24,56 +21,17 @@ private[tournament] case class Player(
|
|||
|
||||
private[tournament] object Player {
|
||||
|
||||
def make(user: User): Player = new Player(
|
||||
private[tournament] def make(user: User): Player = new Player(
|
||||
id = user.id,
|
||||
username = user.username,
|
||||
rating = user.rating)
|
||||
|
||||
def refresh(tour: Tournament): Players = tour.players.map { player =>
|
||||
tour.pairings
|
||||
.filter(p => p.finished && (p contains player.id))
|
||||
.foldLeft(Builder(player))(_ + _.winner)
|
||||
.toPlayer
|
||||
private[tournament] def refresh(tour: Tournament): Players = tour.players map { p =>
|
||||
p.copy(score = Score.sheet(p.id, tour).total)
|
||||
} sortBy { p =>
|
||||
p.withdraw.fold(Int.MaxValue, 0) - p.score
|
||||
}
|
||||
|
||||
private case class Builder(
|
||||
player: Player,
|
||||
nbWin: Int = 0,
|
||||
nbLoss: Int = 0,
|
||||
score: Int = 0,
|
||||
bestWinSeq: Int = 0,
|
||||
wins: List[Boolean] = Nil) {
|
||||
|
||||
def +(winner: Option[String]) = {
|
||||
|
||||
val (win, loss): Pair[Boolean, Boolean] = winner.fold(false -> false) { w =>
|
||||
if (w == player.id) true -> false else false -> true
|
||||
}
|
||||
|
||||
val basePoints = if (win) 2 else if (loss) 0 else 1
|
||||
val isWinStreak = wins take 2 == List(true, true)
|
||||
val points = if (isWinStreak) ti
|
||||
|
||||
val newWinSeq = if (win) prevWin.fold(winSeq + 1, 1) else 0
|
||||
|
||||
copy(
|
||||
nbWin = nbWin + win.fold(1, 0),
|
||||
nbLoss = nbLoss + loss.fold(1, 0),
|
||||
score = score + points,
|
||||
winSeq = newWinSeq,
|
||||
bestWinSeq = math.max(bestWinSeq, newWinSeq),
|
||||
wins = win :: wins)
|
||||
}
|
||||
|
||||
def toPlayer = player.copy(
|
||||
nbWin = nbWin,
|
||||
nbLoss = nbLoss,
|
||||
winStreak = bestWinSeq,
|
||||
score = score)
|
||||
}
|
||||
|
||||
import lila.db.JsTube
|
||||
import JsTube.Helpers._
|
||||
import play.api.libs.json._
|
||||
|
|
49
modules/tournament/src/main/Score.scala
Normal file
49
modules/tournament/src/main/Score.scala
Normal file
|
@ -0,0 +1,49 @@
|
|||
package lila.tournament
|
||||
|
||||
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 isDouble scores
|
||||
}
|
||||
|
||||
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 {
|
||||
val filtered = tour userPairings user filter (_.finished) reverse
|
||||
val nexts = (filtered drop 1 map Some.apply) :+ None
|
||||
filtered.zip(nexts).foldLeft(List[Score]()) {
|
||||
case (scores, (p, n)) => (p.winner match {
|
||||
case None if p.quickDraw => Score(Some(false), Normal)
|
||||
case None => Score(None, if (isDouble(scores)) Double else Normal)
|
||||
case Some(w) if (user == w) => Score(Some(true),
|
||||
if (isDouble(scores)) Double
|
||||
else if (scores.headOption ?? (_.flag == StreakStarter)) StreakStarter
|
||||
else n.flatMap(_.winner) match {
|
||||
case Some(w) if (user == w) => StreakStarter
|
||||
case _ => Normal
|
||||
})
|
||||
case _ => Score(Some(false), Normal)
|
||||
}) :: scores
|
||||
}
|
||||
}
|
||||
|
||||
private def isDouble(scores: List[Score]) =
|
||||
(scores.size >= 2) && (scores take 2 forall (_.flag != Normal))
|
||||
}
|
|
@ -71,6 +71,10 @@ sealed trait Tournament {
|
|||
def createdAt = data.createdAt
|
||||
|
||||
def isCreator(userId: String) = data.createdBy == userId
|
||||
|
||||
def userPairings(user: String) = pairings filter (_ contains user)
|
||||
|
||||
def scoreSheet(player: Player) = Score.sheet(player.id, this)
|
||||
}
|
||||
|
||||
sealed trait Enterable extends Tournament {
|
||||
|
@ -214,9 +218,8 @@ case class Started(
|
|||
"%02d:%02d".format(s / 60, s % 60)
|
||||
}
|
||||
|
||||
def userCurrentPov(userId: String): Option[PovRef] = {
|
||||
playingPairings map { _ povRef userId }
|
||||
}.flatten.headOption
|
||||
def userCurrentPov(userId: String): Option[PovRef] =
|
||||
playingPairings.map { _ povRef userId }.flatten.headOption
|
||||
|
||||
def userCurrentPov(user: Option[User]): Option[PovRef] =
|
||||
user.flatMap(u => userCurrentPov(u.id))
|
||||
|
@ -240,13 +243,10 @@ case class Started(
|
|||
|
||||
def withPlayers(s: Players) = copy(players = s)
|
||||
|
||||
def quickLossStreak(user: String): Boolean = {
|
||||
userPairings(user) takeWhile { pair => (pair lostBy user) && pair.quickLoss }
|
||||
}.size >= 3
|
||||
def quickLossStreak(user: String): Boolean =
|
||||
userPairings(user).takeWhile { pair => (pair lostBy user) && pair.quickLoss }.size >= 3
|
||||
|
||||
private def userPairings(user: String) = pairings filter (_ contains user)
|
||||
|
||||
def refreshPlayers = withPlayers(Player refresh this)
|
||||
private[tournament] def refreshPlayers = withPlayers(Player refresh this)
|
||||
|
||||
def encode = refreshPlayers.encode(Status.Started)
|
||||
|
||||
|
|
|
@ -1669,9 +1669,12 @@ var storage = {
|
|||
if (self.options.messages.length > 0) self._appendMany(self.options.messages);
|
||||
},
|
||||
resize: function() {
|
||||
var headHeight = this.element.parent().height();
|
||||
this.element.css("top", headHeight + 13);
|
||||
this.$msgs.css('height', 459 - headHeight).scrollTop(999999);
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
var headHeight = self.element.parent().height();
|
||||
self.element.css("top", headHeight + 13);
|
||||
self.$msgs.css('height', 459 - headHeight).scrollTop(999999);
|
||||
}, 10);
|
||||
},
|
||||
append: function(msg) {
|
||||
this._appendHtml(this._render(msg));
|
||||
|
@ -2534,9 +2537,8 @@ var storage = {
|
|||
var $wrap = $('#tournament');
|
||||
if (!$wrap.length) return;
|
||||
|
||||
var $userTag = $('#user_tag');
|
||||
|
||||
if (!strongSocket.available) return;
|
||||
|
||||
if (typeof _ld_ == "undefined") {
|
||||
// handle tournament list
|
||||
lichess.socketDefaults.params.flag = "tournament";
|
||||
|
@ -2569,10 +2571,28 @@ var storage = {
|
|||
}
|
||||
startClock();
|
||||
|
||||
function drawBars() {
|
||||
$wrap.find('table.standing').each(function() {
|
||||
var $bars = $(this).find('.bar');
|
||||
var max = _.max($bars.map(function() {
|
||||
return parseInt($(this).data('value'));
|
||||
}));
|
||||
$bars.each(function() {
|
||||
var width = Math.ceil((parseInt($(this).data('value')) * 100) / max);
|
||||
$(this).animate({
|
||||
'width': width + '%'
|
||||
}, 500);
|
||||
});
|
||||
$('#tournament_side').css('maxHeight', $('div.tournament_show').height() - 6 + 'px');
|
||||
});
|
||||
}
|
||||
drawBars();
|
||||
|
||||
function reload() {
|
||||
$wrap.load($wrap.data("href"), function() {
|
||||
startClock();
|
||||
$('body').trigger('lichess.content_loaded');
|
||||
drawBars();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -49,6 +49,56 @@
|
|||
#tournament table.slist .small {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
#tournament div.standing_wrap {
|
||||
max-height: 485px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
#tournament div.standing_wrap:hover {
|
||||
overflow-y: auto;
|
||||
}
|
||||
#tournament table.standing {
|
||||
width: 525px;
|
||||
border-bottom: none;
|
||||
}
|
||||
#tournament table.standing td:first-child, #tournament table.standing th:first-child {
|
||||
padding-left: 15px;
|
||||
}
|
||||
#tournament table.standing td:first-child span {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
}
|
||||
#tournament table.standing .legend {
|
||||
text-align: right;
|
||||
}
|
||||
#tournament table.standing .legend span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
#tournament table.standing .sheet {
|
||||
letter-spacing: -1.5px;
|
||||
}
|
||||
#tournament table.standing .sheet,
|
||||
#tournament table.standing .total {
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
}
|
||||
#tournament .double {
|
||||
color: #d59120;
|
||||
font-weight: bold;
|
||||
}
|
||||
#tournament .streakstarter {
|
||||
color: #759900;
|
||||
font-weight: bold;
|
||||
}
|
||||
#tournament table.standing td.around-bar {
|
||||
padding: 0;
|
||||
}
|
||||
#tournament table.standing div.bar {
|
||||
height: 3px;
|
||||
width: 0;
|
||||
background: #759900;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#tournament form.inline {
|
||||
display: inline;
|
||||
|
@ -113,11 +163,14 @@
|
|||
float: right;
|
||||
width: 246px;
|
||||
max-height: 860px;
|
||||
overflow-y: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid #ccc;
|
||||
padding: 3px;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
#tournament_side:hover {
|
||||
overflow-y: auto;
|
||||
}
|
||||
#tournament_side div.pairings a {
|
||||
padding: 0.3em 0.2em;
|
||||
display: block;
|
||||
|
@ -136,6 +189,9 @@
|
|||
#tournament_side div.pairings span.draw {
|
||||
color: #aaaa00;
|
||||
}
|
||||
#tournament_side div.pairings em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#tournament article.faq h2 {
|
||||
font-weight: bold;
|
||||
|
|
Loading…
Reference in a new issue