Start implementing end game logic

This commit is contained in:
Thibault Duplessis 2012-03-28 23:01:04 +02:00
parent f62fcbf8fb
commit 0da37614fb
24 changed files with 388 additions and 52 deletions

View file

@ -11,7 +11,7 @@ object AppApiC extends LilaController {
private val api = env.appApi
def talk(gameId: String) = Action { implicit request
ValidIOk[TalkData](talkForm)(talk api.talk(gameId, talk._1, talk._2))
FormValidIOk[TalkData](talkForm)(talk api.talk(gameId, talk._1, talk._2))
}
def updateVersion(gameId: String) = Action {
@ -27,23 +27,19 @@ object AppApiC extends LilaController {
}
def draw(gameId: String, color: String) = Action { implicit request
ValidIOk[String](drawForm)(msgs api.draw(gameId, color, msgs))
FormValidIOk[String](drawForm)(msgs api.draw(gameId, color, msgs))
}
def drawAccept(gameId: String, color: String) = Action { implicit request
ValidIOk[String](drawForm)(msgs api.drawAccept(gameId, color, msgs))
}
def end(gameId: String) = Action { implicit request
ValidIOk[String](endForm)(msgs api.end(gameId, msgs))
FormValidIOk[String](drawForm)(msgs api.drawAccept(gameId, color, msgs))
}
def start(gameId: String) = Action { implicit request =>
ValidIOk[EntryData](entryForm)(entryData api.start(gameId, entryData))
FormValidIOk[EntryData](entryForm)(entryData api.start(gameId, entryData))
}
def join(fullId: String) = Action { implicit request
ValidIOk[JoinData](joinForm) { join
FormValidIOk[JoinData](joinForm) { join
api.join(fullId, join._1, join._2, join._3)
}
}
@ -57,7 +53,7 @@ object AppApiC extends LilaController {
}
def rematchAccept(gameId: String, color: String, newGameId: String) = Action { implicit request
ValidIOk[RematchData](rematchForm)(r
FormValidIOk[RematchData](rematchForm)(r
api.rematchAccept(gameId, newGameId, color, r._1, r._2, r._3))
}
}

View file

@ -25,7 +25,19 @@ object AppXhrC extends LilaController {
})
}
def ping() = Action { implicit request =>
def abort(fullId: String) = Action {
ValidIORedir(xhr abort fullId, fullId)
}
def outoftime(fullId: String) = Action {
ValidIORedir(xhr outoftime fullId, fullId)
}
def resign(fullId: String) = Action {
ValidIORedir(xhr resign fullId, fullId)
}
def ping() = Action { implicit request
JsonIOk(env.pinger.ping(
username = get("username"),
playerKey = get("player_key"),
@ -35,7 +47,5 @@ object AppXhrC extends LilaController {
))
}
def nbPlayers() = Action {
Ok(env.aliveMemo.count.toString)
}
def nbPlayers = Action { Ok(env.aliveMemo.count.toString) }
}

View file

@ -24,7 +24,13 @@ trait LilaController extends Controller with ContentTypes with RequestGetter {
_ Ok("ok")
)
def ValidIOk[A](form: Form[A])(op: A IO[Unit])(implicit request: Request[_]) =
def ValidIORedir(op: IO[Valid[Unit]], url: => String) =
op.unsafePerformIO.fold(
failures BadRequest(failures.list mkString "\n"),
_ Redirect("/" + url)
)
def FormValidIOk[A](form: Form[A])(op: A IO[Unit])(implicit request: Request[_]) =
form.bindFromRequest.fold(
form BadRequest(form.errors mkString "\n"),
data IOk(op(data))

View file

@ -12,7 +12,7 @@ object LobbyApiC extends LilaController {
private val api = env.lobbyApi
def join(gameId: String, color: String) = Action { implicit request
ValidIOk[EntryData](entryForm)(entry api.join(gameId, color, entry))
FormValidIOk[EntryData](entryForm)(entry api.join(gameId, color, entry))
}
def create(hookOwnerId: String) = Action {

4
bc
View file

@ -1,5 +1,5 @@
Game.lastMove (and no more in pieces notation)
clock time in milliseconds (and no more floating seconds)
request promotion is not wrapped in options anymore
x request promotion is not wrapped in options anymore
Game.castles (new field in forsyth format)
clock.times => clock.white, clock.black
blurs

View file

@ -95,17 +95,18 @@ case class Board(pieces: Map[Pos, Piece], history: History) {
def count(p: Piece): Int = pieces.values count (_ == p)
def count(c: Color): Int = pieces.values count (_.color == c)
def autoDraw: Boolean = {
history.positionHashes.size > 100 || (Color.all map rolesOf forall { roles
(roles filterNot (_ == King)) match {
case roles if roles.size > 1 false
case List(Knight) true
case List(Bishop) true
case Nil true
case _ false
}
})
}
def autoDraw: Boolean =
history.positionHashes.size > 100 ||
(Color.all forall { !hasEnoughMaterialToMate(_) })
def hasEnoughMaterialToMate(color: Color) =
rolesOf(color) filterNot (_ == King) match {
case roles if roles.size > 1 true
case List(Knight) false
case List(Bishop) false
case Nil false
case _ true
}
def positionHash = Hasher(actors.values map (_.hash) mkString).md5.toString

View file

@ -13,7 +13,7 @@ sealed trait Clock {
def time(c: Color) = if (c == White) whiteTime else blackTime
def isOutOfTime(c: Color) = remainingTime(c) == 0
def outoftime(c: Color) = remainingTime(c) == 0
def remainingTime(c: Color) = max(0, limit - elapsedTime(c))

View file

@ -0,0 +1,42 @@
package lila.chess
import scala.math.round
final class EloCalculator {
// Player 1 wins
val P1WIN = -1;
// No player wins
val DRAW = 0;
// Player 2 wins
val P2WIN = 1;
type User = {
def elo: Int
def nbRatedGames: Int
}
def calculate(user1: User, user2: User, win: Option[Color]): (Int, Int) = {
val winCode = win match {
case None DRAW
case Some(White) P1WIN
case Some(Black) P2WIN
}
(calculateUserElo(user1, user2.elo, -winCode),
calculateUserElo(user2, user1.elo, winCode))
}
private def calculateUserElo(user: User, opponentElo: Int, win: Int) = {
val score = (1 + win) / 2f
val expected = 1 / (1 + math.pow(10, (opponentElo - user.elo) / 400f))
val kFactor = math.round(
if (user.nbRatedGames > 20) 16
else 50 - user.nbRatedGames * (34 / 20f)
)
val diff = 2 * kFactor * (score - expected)
round(user.elo + diff).toInt
}
}

View file

@ -0,0 +1,71 @@
package lila.chess
class EloTest extends ChessTest {
val calc = new EloCalculator
import calc._
def user(e: Int, n: Int) = new {
val elo = e
val nbGames = n
}
"calculate standard" should {
"with equal elo" in {
val (u1, u2) = (user(1400, 56), user(1400, 389))
"p1 win" in {
val (nu1, nu2) = calculate(u1, u2, -1)
"new elos" in {
(nu1, nu2) must_== (1416, 1384)
}
"conservation rule" in {
nu1 - u1.elo + nu2 - u2.elo must_== 0
}
}
"p1 loss" in {
val (u1, u2) = (user(1400, 56), user(1400, 389))
val (nu1, nu2) = calculate(u1, u2, 1)
"new elos" in {
(nu1, nu2) must_== (1384, 1416)
}
"conservation rule" in {
nu1 - u1.elo + nu2 - u2.elo must_== 0
}
}
"draw" in {
val (u1, u2) = (user(1400, 56), user(1400, 389))
val (nu1, nu2) = calculate(u1, u2, 0)
"new elos" in {
(nu1, nu2) must_== (1400, 1400)
}
"conservation rule" in {
nu1 - u1.elo + nu2 - u2.elo must_== 0
}
}
}
"loss" in {
val (u1, u2) = (user(1613, 56), user(1388, 389))
val (nu1, nu2) = calculate(u1, u2, -1)
"new elos" in {
(nu1, nu2) must_== (1620, 1381)
}
"conservation rule" in {
nu1 - u1.elo + nu2 - u2.elo must_== 0
}
}
}
"provision" should {
val (u1, u2) = (user(1613, 8), user(1388, 389))
val (nu1, nu2) = calculate(u1, u2, -1)
"new elos" in {
(nu1, nu2) must_== (1628, 1381)
}
}
"no provision" should {
val (u1, u2) = (user(1313, 1256), user(1158, 124))
val (nu1, nu2) = calculate(u1, u2, -1)
"new elos" in {
(nu1, nu2) must_== (1322, 1149)
}
}
}

View file

@ -8,6 +8,7 @@ mongo {
hook = hook
entry = lobby_entry
message = lobby_message
history = user_history
}
}
redis {
@ -35,6 +36,7 @@ memo {
watcher.timeout = 6 seconds
username.timeout = 10 seconds
hook.timeout = 6 seconds
finisher_lock.timeout = 30 seconds
}
crafty {
exec_path = "/usr/bin/crafty"

View file

@ -8,11 +8,13 @@ GET /sync/:gameId/:color/:version controllers.AppXhrC.syncPublic(gameId: S
GET /sync/:gameId/:color/:version/:fullId controllers.AppXhrC.sync(gameId: String, color: String, version: Int, fullId: String)
GET /how-many-players-now controllers.AppXhrC.nbPlayers
POST /move/:fullId controllers.AppXhrC.move(fullId: String)
GET /abort/:fullId controllers.AppXhrC.abort(fullId: String)
GET /resign/:fullId controllers.AppXhrC.resign(fullId: String)
POST /outoftime/:fullId controllers.AppXhrC.outoftime(fullId: String)
# App Private API
POST /api/update-version/:gameId controllers.AppApiC.updateVersion(gameId: String)
POST /api/start/:gameId controllers.AppApiC.start(gameId: String)
POST /api/end/:gameId controllers.AppApiC.end(gameId: String)
POST /api/talk/:fullId controllers.AppApiC.talk(fullId: String)
POST /api/join/:fullId controllers.AppApiC.join(fullId: String)
POST /api/reload-table/:gameId controllers.AppApiC.reloadTable(gameId: String)

View file

@ -32,12 +32,6 @@ case class AppApi(
_ save(g1, g2)
} yield ()
def end(gameId: String, messages: String): IO[Unit] = for {
g1 gameRepo game gameId
g2 = g1 withEvents (EndEvent() :: decodeMessages(messages))
_ save(g1, g2)
} yield ()
def start(gameId: String, entryData: String): IO[Unit] = for {
game gameRepo game gameId
_ addEntry(game, entryData)

View file

@ -7,16 +7,19 @@ import lila.chess._
import Pos.posAt
import scalaz.effects._
final class AppXhr(
case class AppXhr(
gameRepo: GameRepo,
ai: Ai,
finisher: Finisher,
versionMemo: VersionMemo,
aliveMemo: AliveMemo) {
aliveMemo: AliveMemo) extends IOTools {
type IOValid = IO[Valid[Unit]]
def playMove(
fullId: String,
moveString: String,
promString: Option[String] = None): IO[Valid[Unit]] =
promString: Option[String] = None): IOValid =
(decodeMoveString(moveString) toValid "Wrong move").fold(
e io(failure(e)),
move play(fullId, move._1, move._2, promString)
@ -26,7 +29,7 @@ final class AppXhr(
fullId: String,
fromString: String,
toString: String,
promString: Option[String] = None): IO[Valid[Unit]] =
promString: Option[String] = None): IOValid =
gameRepo player fullId flatMap {
case (g1, player) purePlay(g1, fromString, toString, promString).fold(
e io(failure(e)),
@ -43,6 +46,22 @@ final class AppXhr(
)
}
def abort(fullId: String): IOValid = for {
game gameRepo playerGame fullId
res (finisher abort game).sequence
} yield res
def resign(fullId: String): IOValid = for {
gameAndPlayer gameRepo player fullId
(game, player) = gameAndPlayer
res (finisher.resign(game, player.color)).sequence
} yield res
def outoftime(fullId: String): IOValid = for {
game gameRepo playerGame fullId
res (finisher outoftime game).sequence
} yield res
private def purePlay(
g1: DbGame,
origString: String,

View file

@ -0,0 +1,75 @@
package lila.system
import db.{ UserRepo, GameRepo, HistoryRepo }
import model._
import memo.{ VersionMemo, FinisherLock }
import lila.chess.{ Color, White, Black, EloCalculator }
import scalaz.effects._
final class Finisher(
historyRepo: HistoryRepo,
userRepo: UserRepo,
gameRepo: GameRepo,
versionMemo: VersionMemo,
eloCalculator: EloCalculator,
finisherLock: FinisherLock) {
type ValidIO = Valid[IO[Unit]]
def abort(game: DbGame): ValidIO =
if (game.abortable) finish(game, Aborted)
else !!("game is not abortable")
def resign(game: DbGame, color: Color): ValidIO =
if (game.resignable) finish(game, Resign, Some(!color))
else !!("game is not resignable")
def outoftime(game: DbGame): ValidIO =
game.outoftimePlayer some { player
finish(game, Outoftime,
Some(!player.color) filter game.toChess.board.hasEnoughMaterialToMate)
} none !!("no outoftime applicable")
private def !!(msg: String) = failure(msg.wrapNel)
private def finish(
game: DbGame,
status: Status,
winner: Option[Color] = None,
message: Option[String] = None): ValidIO =
if (finisherLock isLocked game) !!("game finish is locked")
else success(for {
_ finisherLock lock game
g2 = game.finish(status, winner, message)
_ gameRepo.applyDiff(game, g2)
_ versionMemo put g2
_ updateElo(g2)
_ incNbGames(g2, White)
_ incNbGames(g2, Black)
} yield ())
private def incNbGames(game: DbGame, color: Color) =
game.player(color).userId.fold(
id userRepo.incNbGames(id, game.rated),
io()
)
private def updateElo(game: DbGame): IO[Unit] =
if (!game.finished || !game.rated || game.turns < 2) io()
else {
for {
whiteUserId game.player(White).userId
blackUserId game.player(Black).userId
if whiteUserId != blackUserId
} yield for {
whiteUser userRepo user whiteUserId
blackUser userRepo user blackUserId
(whiteElo, blackElo) = eloCalculator.calculate(whiteUser, blackUser, game.winnerColor)
_ userRepo.setElo(whiteUser.id, whiteElo)
_ userRepo.setElo(blackUser.id, blackElo)
_ historyRepo.addEntry(whiteUser.username, whiteElo, game.id)
_ historyRepo.addEntry(blackUser.username, blackElo, game.id)
} yield ()
} | io()
}

View file

@ -3,6 +3,7 @@ package lila.system
import com.mongodb.casbah.MongoConnection
import com.typesafe.config._
import lila.chess.EloCalculator
import db._
import ai._
import memo._
@ -12,6 +13,7 @@ final class SystemEnv(config: Config) {
lazy val appXhr = new AppXhr(
gameRepo = gameRepo,
ai = ai,
finisher = finisher,
versionMemo = versionMemo,
aliveMemo = aliveMemo)
@ -63,6 +65,15 @@ final class SystemEnv(config: Config) {
watcherMemo = watcherMemo,
hookMemo = hookMemo)
lazy val finisher = new Finisher(
historyRepo = historyRepo,
userRepo = userRepo,
gameRepo = gameRepo,
versionMemo = versionMemo,
eloCalculator = new EloCalculator,
finisherLock = new FinisherLock(
timeout = getMilliseconds("memo.finisher_lock.timeout")))
lazy val ai: Ai = craftyAi
lazy val craftyAi = new CraftyAi(
@ -86,6 +97,10 @@ final class SystemEnv(config: Config) {
collection = mongodb(config getString "mongo.collection.message"),
max = config getInt "lobby.message.max")
lazy val historyRepo = new HistoryRepo(
collection = mongodb(config getString "mongo.collection.history")
)
lazy val mongodb = MongoConnection(
config getString "mongo.host",
config getInt "mongo.port"

View file

@ -0,0 +1,25 @@
package lila.system
package db
import com.mongodb.casbah.MongoCollection
import com.mongodb.casbah.Imports._
import scalaz.effects._
final class HistoryRepo(collection: MongoCollection) {
val entryType = 2
def addEntry(username: String, elo: Int, gameId: String): IO[Unit] = io {
val tsKey = (System.currentTimeMillis / 1000).toString
collection.update(
DBObject("_id" -> username),
DBObject("$set" -> (("entries." + tsKey) -> DBObject(
"t" -> entryType,
"e" -> elo,
"g" -> gameId
))),
false, false
)
}
}

View file

@ -12,6 +12,25 @@ import scalaz.effects._
class UserRepo(collection: MongoCollection)
extends SalatDAO[User, ObjectId](collection) {
def user(userId: String): IO[User] = user(new ObjectId(userId))
def user(userId: ObjectId): IO[User] = io {
findOneByID(userId) err "No user found for id " + userId
}
def setElo(userId: ObjectId, elo: Int): IO[Unit] = io {
collection.update(
DBObject("_id" -> userId),
$set ("elo" -> elo))
}
def incNbGames(userId: String, rated: Boolean): IO[Unit] = io {
collection.update(
DBObject("_id" -> new ObjectId(userId)),
if (rated) $inc ("nbGames" -> 1, "nbRatedGames" -> 1)
else $inc ("nbGames" -> 1))
}
def updateOnlineUsernames(usernames: Iterable[String]): IO[Unit] = io {
val names = usernames map (_.toLowerCase)
collection.update(

View file

@ -0,0 +1,12 @@
package lila.system
package memo
import model.DbGame
import scalaz.effects._
final class FinisherLock(timeout: Int) extends BooleanExpiryMemo(timeout) {
def isLocked(game: DbGame): Boolean = get(game.id)
def lock(game: DbGame): IO[Unit] = put(game.id)
}

View file

@ -19,7 +19,8 @@ case class DbGame(
positionHashes: String = "",
castles: String = "KQkq",
isRated: Boolean = false,
variant: Variant = Standard) {
variant: Variant = Standard,
winnerId: Option[String]) {
val players = List(whitePlayer, blackPlayer)
@ -163,6 +164,29 @@ case class DbGame(
player(color).lastDrawOffer some (_ >= turns - 1) none false
def abortable = status == Started && turns < 2
def resignable = playable && !abortable
def finish(status: Status, winner: Option[Color], msg: Option[String]) = copy(
status = status,
winnerId = winner flatMap (player(_).userId),
whitePlayer = whitePlayer finish (winner == White),
blackPlayer = blackPlayer finish (winner == Black),
positionHashes = ""
) withEvents (EndEvent() :: msg.map(MessageEvent("system", _)).toList)
def rated = isRated
def finished = status >= Mate
def winnerColor: Option[Color] = players find (_.wins) map (_.color)
def outoftimePlayer: Option[DbPlayer] = for {
c clock
if this.playable
if c outoftime player.color
} yield player
}
object DbGame {

View file

@ -3,6 +3,8 @@ package model
import lila.chess._
import com.mongodb.DBRef
case class DbPlayer(
id: String,
color: Color,
@ -12,7 +14,8 @@ case class DbPlayer(
evts: String = "",
elo: Option[Int],
isOfferingDraw: Boolean,
lastDrawOffer: Option[Int]) {
lastDrawOffer: Option[Int],
user: Option[DBRef]) {
def eventStack: EventStack = EventStack decode evts
@ -32,4 +35,12 @@ case class DbPlayer(
} mkString " "
def isAi = aiLevel.isDefined
def userId: Option[String] = user map (_.getId.toString)
def wins = isWinner getOrElse false
def finish(winner: Boolean) = copy(
isWinner = if (winner) Some(true) else None
)
}

View file

@ -20,7 +20,8 @@ case class RawDbGame(
positionHashes: String = "",
castles: String = "KQkq",
isRated: Boolean = false,
variant: Int = 1) {
variant: Int = 1,
winnerId: Option[String] = None) {
def decode: Option[DbGame] = for {
whitePlayer players find (_.color == "white") flatMap (_.decode)
@ -44,7 +45,8 @@ case class RawDbGame(
positionHashes = positionHashes,
castles = castles,
isRated = isRated,
variant = trueVariant
variant = trueVariant,
winnerId = winnerId
)
}
@ -65,7 +67,8 @@ object RawDbGame {
positionHashes = positionHashes,
castles = castles,
isRated = isRated,
variant = variant.id
variant = variant.id,
winnerId = winnerId
)
}
}

View file

@ -3,6 +3,8 @@ package model
import lila.chess._
import com.mongodb.DBRef
case class RawDbPlayer(
id: String,
color: String,
@ -12,7 +14,8 @@ case class RawDbPlayer(
evts: String = "",
elo: Option[Int],
isOfferingDraw: Option[Boolean],
lastDrawOffer: Option[Int]) {
lastDrawOffer: Option[Int],
user: Option[DBRef]) {
def decode: Option[DbPlayer] = for {
trueColor Color(color)
@ -25,7 +28,8 @@ case class RawDbPlayer(
evts = evts,
elo = elo,
isOfferingDraw = isOfferingDraw getOrElse false,
lastDrawOffer = lastDrawOffer
lastDrawOffer = lastDrawOffer,
user = user
)
}
@ -42,7 +46,8 @@ object RawDbPlayer {
evts = evts,
elo = elo,
isOfferingDraw = if (isOfferingDraw) Some(true) else None,
lastDrawOffer = lastDrawOffer
lastDrawOffer = lastDrawOffer,
user = user
)
}
}

View file

@ -6,5 +6,8 @@ import com.mongodb.casbah.Imports.ObjectId
case class User(
id: ObjectId,
username: String,
isOnline: Boolean) {
isOnline: Boolean,
elo: Int,
nbGames: Int,
nbRatedGames: Int) {
}

5
todo
View file

@ -1,2 +1,3 @@
start ai with black
outoftime
messenger (use it to display end of game status string)
move times
blurs