new tournament scoring system and UI

This commit is contained in:
Thibault Duplessis 2014-04-21 16:44:49 +02:00
parent db095f12c0
commit 8c9b127701
10 changed files with 204 additions and 113 deletions

View file

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

View file

@ -11,6 +11,7 @@ moreJs = moreJs,
goodies = goodies,
chat = chat,
active = siteMenu.tournament.some,
themepicker = true,
underchat = underchat) {
@body
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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