Merge branch 'stockfish'
* stockfish: upgrade scalachess and increase AI aggressiveness tweak featured game elo heuristic upgrade to scalachess 2.0 fix ai server http api lazy FSM queue that picks easy jobs first refactor AI clients and servers upgrade scalachess to 1.17 to fix stockfish 960 castle chess960 UCI fen support chess960 with stockfish AI fix AI name improve and configure stockfish AI first version of working engine FSM! progress on stockfish AI Conflicts: app/core/Global.scala todo
This commit is contained in:
commit
385e98415b
|
@ -8,5 +8,5 @@ import scalaz.effects._
|
|||
|
||||
trait Ai {
|
||||
|
||||
def apply(dbGame: DbGame): IO[Valid[(Game, Move)]]
|
||||
def apply(dbGame: DbGame, initialFen: Option[String]): IO[Valid[(Game, Move)]]
|
||||
}
|
||||
|
|
|
@ -5,26 +5,46 @@ import com.mongodb.casbah.MongoCollection
|
|||
|
||||
import core.Settings
|
||||
|
||||
final class AiEnv(
|
||||
settings: Settings) {
|
||||
final class AiEnv(settings: Settings) {
|
||||
|
||||
import settings._
|
||||
|
||||
val ai: () ⇒ Ai = AiChoice match {
|
||||
case AiRemote ⇒ () ⇒ remoteAi or craftyAi
|
||||
case AiCrafty ⇒ () ⇒ craftyAi
|
||||
case _ ⇒ () ⇒ stupidAi
|
||||
val ai: () ⇒ Ai = (AiChoice, AiClientMode) match {
|
||||
case (AiStockfish, false) ⇒ () ⇒ stockfishAi
|
||||
case (AiStockfish, true) ⇒ () ⇒ stockfishClient or stockfishAi
|
||||
case (AiCrafty, false) ⇒ () ⇒ craftyAi
|
||||
case (AiCrafty, true) ⇒ () ⇒ craftyClient or craftyAi
|
||||
case _ ⇒ () ⇒ stupidAi
|
||||
}
|
||||
|
||||
lazy val remoteAi = new RemoteAi(remoteUrl = AiRemoteUrl)
|
||||
lazy val client: Client = AiChoice match {
|
||||
case AiStockfish ⇒ stockfishClient
|
||||
case AiCrafty ⇒ craftyClient
|
||||
}
|
||||
|
||||
lazy val craftyAi = new CraftyAi(server = craftyServer)
|
||||
lazy val craftyAi = new crafty.Ai(
|
||||
server = craftyServer)
|
||||
|
||||
lazy val craftyServer = new CraftyServer(
|
||||
lazy val craftyClient = new crafty.Client(
|
||||
remoteUrl = AiCraftyRemoteUrl)
|
||||
|
||||
lazy val craftyServer = new crafty.Server(
|
||||
execPath = AiCraftyExecPath,
|
||||
bookPath = AiCraftyBookPath)
|
||||
|
||||
lazy val stockfishAi = new stockfish.Ai(
|
||||
server = stockfishServer)
|
||||
|
||||
lazy val stockfishClient = new stockfish.Client(
|
||||
remoteUrl = AiStockfishRemoteUrl)
|
||||
|
||||
lazy val stockfishServer = new stockfish.Server(
|
||||
execPath = AiStockfishExecPath,
|
||||
config = stockfishConfig)
|
||||
|
||||
lazy val stockfishConfig = new stockfish.Config(settings)
|
||||
|
||||
lazy val stupidAi = new StupidAi
|
||||
|
||||
def isServer = AiServerMode
|
||||
val isServer = AiServerMode
|
||||
}
|
||||
|
|
13
app/ai/AiHelper.scala
Normal file
13
app/ai/AiHelper.scala
Normal file
|
@ -0,0 +1,13 @@
|
|||
package lila
|
||||
package ai
|
||||
|
||||
import http.Context
|
||||
import i18n.I18nHelper
|
||||
|
||||
trait AiHelper extends I18nHelper {
|
||||
|
||||
def aiName = "Stockfish AI"
|
||||
|
||||
def aiName(level: Int)(implicit ctx: Context) =
|
||||
trans.aiNameLevelAiLevel("Crafty A.I.", level)
|
||||
}
|
34
app/ai/Client.scala
Normal file
34
app/ai/Client.scala
Normal file
|
@ -0,0 +1,34 @@
|
|||
package lila
|
||||
package ai
|
||||
|
||||
import dispatch.{ Http, NoLogging, url }
|
||||
import dispatch.thread.{ Safety ⇒ ThreadSafety }
|
||||
import scalaz.effects._
|
||||
|
||||
trait Client extends Ai {
|
||||
|
||||
val remoteUrl: String
|
||||
|
||||
protected def tryPing: IO[Option[Int]]
|
||||
|
||||
// tells whether the remote AI is healthy or not
|
||||
// frequently updated by a scheduled actor
|
||||
protected var ping = none[Int]
|
||||
protected val pingAlert = 3000
|
||||
|
||||
protected lazy val http = new Http with ThreadSafety with NoLogging
|
||||
protected lazy val urlObj = url(remoteUrl)
|
||||
|
||||
def or(fallback: Ai) = if (currentHealth) this else fallback
|
||||
|
||||
def currentPing = ping
|
||||
def currentHealth = ping.fold(_ < pingAlert, false)
|
||||
|
||||
def diagnose: IO[Unit] = for {
|
||||
p ← tryPing
|
||||
_ ← p.fold(_ < pingAlert, false).fold(
|
||||
currentHealth.fold(io(), putStrLn("remote AI is up, ping = " + p)),
|
||||
putStrLn("remote AI is down, ping = " + p))
|
||||
_ ← io { ping = p }
|
||||
} yield ()
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
package lila
|
||||
package ai
|
||||
|
||||
import chess.{ Game, Move }
|
||||
import game.DbGame
|
||||
|
||||
import scalaz.effects._
|
||||
import dispatch.{ Http, NoLogging, url }
|
||||
import dispatch.thread.{ Safety ⇒ ThreadSafety }
|
||||
|
||||
final class RemoteAi(
|
||||
remoteUrl: String) extends Ai with FenBased {
|
||||
|
||||
// tells whether the remote AI is healthy or not
|
||||
// frequently updated by a scheduled actor
|
||||
private var ping = none[Int]
|
||||
val pingAlert = 3000
|
||||
|
||||
private lazy val http = new Http with ThreadSafety with NoLogging
|
||||
private lazy val urlObj = url(remoteUrl)
|
||||
|
||||
def apply(dbGame: DbGame): IO[Valid[(Game, Move)]] = {
|
||||
|
||||
val oldGame = dbGame.toChess
|
||||
val oldFen = toFen(oldGame, dbGame.variant)
|
||||
|
||||
fetchNewFen(oldFen, dbGame.aiLevel | 1) map { newFen ⇒
|
||||
applyFen(oldGame, newFen)
|
||||
}
|
||||
}
|
||||
|
||||
def or(fallback: Ai) = if (currentHealth) this else fallback
|
||||
|
||||
def currentPing = ping
|
||||
def currentHealth = ping.fold(_ < pingAlert, false)
|
||||
|
||||
def diagnose: IO[Unit] = for {
|
||||
p ← tryPing
|
||||
_ ← p.fold(_ < pingAlert, false).fold(
|
||||
currentHealth.fold(io(), putStrLn("remote AI is up, ping = " + p)),
|
||||
putStrLn("remote AI is down, ping = " + p))
|
||||
_ ← io { ping = p }
|
||||
} yield ()
|
||||
|
||||
private def fetchNewFen(oldFen: String, level: Int): IO[String] = io {
|
||||
http(urlObj <<? Map(
|
||||
"fen" -> oldFen,
|
||||
"level" -> level.toString
|
||||
) as_str)
|
||||
}
|
||||
|
||||
private def tryPing: IO[Option[Int]] = for {
|
||||
start ← io(nowMillis)
|
||||
received ← fetchNewFen(
|
||||
oldFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq",
|
||||
level = 1
|
||||
).catchLeft map (_.isRight)
|
||||
delay ← io(nowMillis - start)
|
||||
} yield received option delay.toInt
|
||||
}
|
|
@ -8,7 +8,7 @@ import scalaz.effects._
|
|||
|
||||
final class StupidAi extends Ai {
|
||||
|
||||
def apply(dbGame: DbGame): IO[Valid[(Game, Move)]] = io {
|
||||
def apply(dbGame: DbGame, initialFen: Option[String]): IO[Valid[(Game, Move)]] = io {
|
||||
|
||||
val game = dbGame.toChess
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package lila
|
||||
package ai
|
||||
package crafty
|
||||
|
||||
import chess.{ Game, Move }
|
||||
import game.DbGame
|
||||
|
@ -9,9 +10,9 @@ import scala.io.Source
|
|||
import scala.sys.process.Process
|
||||
import scalaz.effects._
|
||||
|
||||
final class CraftyAi(server: CraftyServer) extends Ai with FenBased {
|
||||
final class Ai(server: Server) extends ai.Ai with FenBased {
|
||||
|
||||
def apply(dbGame: DbGame): IO[Valid[(Game, Move)]] = {
|
||||
def apply(dbGame: DbGame, initialFen: Option[String]): IO[Valid[(Game, Move)]] = {
|
||||
|
||||
val oldGame = dbGame.toChess
|
||||
val oldFen = toFen(oldGame, dbGame.variant)
|
37
app/ai/crafty/Client.scala
Normal file
37
app/ai/crafty/Client.scala
Normal file
|
@ -0,0 +1,37 @@
|
|||
package lila
|
||||
package ai
|
||||
package crafty
|
||||
|
||||
import chess.{ Game, Move }
|
||||
import game.DbGame
|
||||
|
||||
import scalaz.effects._
|
||||
|
||||
final class Client(val remoteUrl: String) extends ai.Client with FenBased {
|
||||
|
||||
def apply(dbGame: DbGame, initialFen: Option[String]): IO[Valid[(Game, Move)]] = {
|
||||
|
||||
val oldGame = dbGame.toChess
|
||||
val oldFen = toFen(oldGame, dbGame.variant)
|
||||
|
||||
fetchNewFen(oldFen, dbGame.aiLevel | 1) map { newFen ⇒
|
||||
applyFen(oldGame, newFen)
|
||||
}
|
||||
}
|
||||
|
||||
private def fetchNewFen(oldFen: String, level: Int): IO[String] = io {
|
||||
http(urlObj <<? Map(
|
||||
"fen" -> oldFen,
|
||||
"level" -> level.toString
|
||||
) as_str)
|
||||
}
|
||||
|
||||
protected def tryPing: IO[Option[Int]] = for {
|
||||
start ← io(nowMillis)
|
||||
received ← fetchNewFen(
|
||||
oldFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq",
|
||||
level = 1
|
||||
).catchLeft map (_.isRight)
|
||||
delay ← io(nowMillis - start)
|
||||
} yield received option delay.toInt
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package lila
|
||||
package ai
|
||||
package crafty
|
||||
|
||||
import chess.{ Game, Move }
|
||||
|
||||
|
@ -8,7 +9,7 @@ import scala.io.Source
|
|||
import scala.sys.process.Process
|
||||
import scalaz.effects._
|
||||
|
||||
final class CraftyServer(
|
||||
final class Server(
|
||||
execPath: String,
|
||||
bookPath: Option[String] = None) {
|
||||
|
||||
|
@ -20,11 +21,10 @@ final class CraftyServer(
|
|||
def runCrafty(oldFen: String, level: Int): IO[String] =
|
||||
io { Process(command(level)) #< input(oldFen, level) !! } map extractFen
|
||||
|
||||
private def extractFen(output: String) = {
|
||||
private def extractFen(output: String) =
|
||||
output.lines.find(_ contains "setboard") map { line ⇒
|
||||
"""^.+setboard\s([\w\d/]+\s\w).*$""".r.replaceAllIn(line, m ⇒ m group 1)
|
||||
} getOrElse "Crafty output does not contain setboard"
|
||||
}
|
||||
|
||||
private def command(level: Int) =
|
||||
"""%s learn=off log=off bookpath=%s ponder=off smpmt=1 st=%s""".format(
|
21
app/ai/stockfish/Ai.scala
Normal file
21
app/ai/stockfish/Ai.scala
Normal file
|
@ -0,0 +1,21 @@
|
|||
package lila
|
||||
package ai
|
||||
package stockfish
|
||||
|
||||
import chess.{ Game, Move }
|
||||
import game.DbGame
|
||||
|
||||
import scalaz.effects._
|
||||
|
||||
final class Ai(server: Server) extends lila.ai.Ai with Stockfish {
|
||||
|
||||
import model._
|
||||
|
||||
def apply(dbGame: DbGame, initialFen: Option[String]): IO[Valid[(Game, Move)]] =
|
||||
server(dbGame.pgn, initialFen, dbGame.aiLevel | 1).fold(
|
||||
err ⇒ io(failure(err)),
|
||||
iop ⇒ iop map {
|
||||
applyMove(dbGame, _)
|
||||
}
|
||||
)
|
||||
}
|
35
app/ai/stockfish/Client.scala
Normal file
35
app/ai/stockfish/Client.scala
Normal file
|
@ -0,0 +1,35 @@
|
|||
package lila
|
||||
package ai
|
||||
package stockfish
|
||||
|
||||
import chess.{ Game, Move }
|
||||
import game.DbGame
|
||||
|
||||
import scalaz.effects._
|
||||
|
||||
final class Client(val remoteUrl: String) extends ai.Client with Stockfish {
|
||||
|
||||
def apply(dbGame: DbGame, initialFen: Option[String]): IO[Valid[(Game, Move)]] = {
|
||||
fetchMove(dbGame.pgn, initialFen | "", dbGame.aiLevel | 1) map {
|
||||
applyMove(dbGame, _)
|
||||
}
|
||||
}
|
||||
|
||||
protected def tryPing: IO[Option[Int]] = for {
|
||||
start ← io(nowMillis)
|
||||
received ← fetchMove(
|
||||
pgn = "",
|
||||
initialFen = "",
|
||||
level = 1
|
||||
).catchLeft map (_.isRight)
|
||||
delay ← io(nowMillis - start)
|
||||
} yield received option delay.toInt
|
||||
|
||||
private def fetchMove(pgn: String, initialFen: String, level: Int): IO[String] = io {
|
||||
http(urlObj <<? Map(
|
||||
"pgn" -> pgn,
|
||||
"initialFen" -> initialFen,
|
||||
"level" -> level.toString
|
||||
) as_str)
|
||||
}
|
||||
}
|
33
app/ai/stockfish/Config.scala
Normal file
33
app/ai/stockfish/Config.scala
Normal file
|
@ -0,0 +1,33 @@
|
|||
package lila
|
||||
package ai.stockfish
|
||||
|
||||
import model._
|
||||
import core.Settings
|
||||
|
||||
final class Config(settings: Settings) {
|
||||
|
||||
type Instructions = List[String]
|
||||
|
||||
def init: Instructions = List(
|
||||
setoption("Hash", settings.AiStockfishHashSize),
|
||||
setoption("Threads", 8),
|
||||
setoption("Ponder", false),
|
||||
setoption("Aggressiveness", settings.AiStockfishAggressiveness) // 0 - 200
|
||||
)
|
||||
|
||||
def game(play: Play): Instructions = List(
|
||||
setoption("Skill Level", skill(play.level)),
|
||||
setoption("UCI_Chess960", play.chess960)
|
||||
)
|
||||
|
||||
def maxMoveTime = 500
|
||||
|
||||
def moveTime(level: Int): Int =
|
||||
maxMoveTime / (level >= 1 && level <= 8).fold(9 - level, 8)
|
||||
|
||||
private def skill(level: Int) =
|
||||
(level >= 1 && level <= 8).fold(math.round((level -1) * (20 / 7f)), 0)
|
||||
|
||||
private def setoption(name: String, value: Any) =
|
||||
"setoption name %s value %s".format(name, value)
|
||||
}
|
72
app/ai/stockfish/FSM.scala
Normal file
72
app/ai/stockfish/FSM.scala
Normal file
|
@ -0,0 +1,72 @@
|
|||
package lila
|
||||
package ai.stockfish
|
||||
|
||||
import model._
|
||||
|
||||
import akka.actor.{ Props, Actor, ActorRef, FSM ⇒ AkkaFSM, LoggingFSM }
|
||||
import scalaz.effects._
|
||||
|
||||
final class FSM(
|
||||
processBuilder: (String ⇒ Unit, String ⇒ Unit) ⇒ Process,
|
||||
config: Config)
|
||||
extends Actor with LoggingFSM[model.State, model.Data] {
|
||||
|
||||
val process = processBuilder(out ⇒ self ! Out(out), err ⇒ self ! Err(err))
|
||||
|
||||
startWith(Starting, Todo(Vector.empty))
|
||||
|
||||
when(Starting) {
|
||||
case Event(Out(t), _) if t startsWith "Stockfish" ⇒ {
|
||||
process write "uci"
|
||||
stay
|
||||
}
|
||||
case Event(Out(t), data) if t contains "uciok" ⇒ {
|
||||
config.init foreach process.write
|
||||
next(data)
|
||||
}
|
||||
case Event(play: Play, data) ⇒
|
||||
stay using (data enqueue Task(play, sender))
|
||||
}
|
||||
when(Ready) {
|
||||
case Event(Out(t), _) ⇒ { log.warning(t); stay }
|
||||
}
|
||||
when(UciNewGame) {
|
||||
case Event(Out(t), data @ Doing(Task(play, _), _)) if t contains "readyok" ⇒ {
|
||||
process write play.position
|
||||
process write (play go config.moveTime)
|
||||
goto(Go)
|
||||
}
|
||||
}
|
||||
when(Go) {
|
||||
case Event(Out(t), data @ Doing(Task(_, ref), _)) if t contains "bestmove" ⇒ {
|
||||
ref ! BestMove(t.split(' ') lift 1)
|
||||
goto(Ready) using data.done
|
||||
}
|
||||
}
|
||||
whenUnhandled {
|
||||
case Event(play: Play, data) ⇒
|
||||
next(data enqueue Task(play, sender))
|
||||
case Event(Out(""), _) ⇒ stay
|
||||
case Event(Out(t), _) if t startsWith "id " ⇒ stay
|
||||
case Event(Out(t), _) if t startsWith "info " ⇒ stay
|
||||
case Event(Out(t), _) if t startsWith "option name " ⇒ stay
|
||||
case Event(Err(t), _) ⇒ { log.error(t); stay }
|
||||
}
|
||||
|
||||
def next(data: Data) = data match {
|
||||
case todo: Todo ⇒ todo.doing(
|
||||
doing ⇒ {
|
||||
config game doing.current.play foreach process.write
|
||||
process write "ucinewgame"
|
||||
process write "isready"
|
||||
goto(UciNewGame) using doing
|
||||
},
|
||||
t ⇒ goto(Ready) using t
|
||||
)
|
||||
case doing: Doing ⇒ stay using data
|
||||
}
|
||||
|
||||
def onTermination() {
|
||||
process.destroy()
|
||||
}
|
||||
}
|
55
app/ai/stockfish/Process.scala
Normal file
55
app/ai/stockfish/Process.scala
Normal file
|
@ -0,0 +1,55 @@
|
|||
package lila
|
||||
package ai.stockfish
|
||||
|
||||
import java.io.OutputStream
|
||||
import scala.sys.process.{ Process => SProcess, ProcessBuilder, ProcessIO }
|
||||
import scala.io.Source.fromInputStream
|
||||
|
||||
final class Process(
|
||||
builder: ProcessBuilder,
|
||||
out: String ⇒ Unit,
|
||||
err: String ⇒ Unit,
|
||||
debug: Boolean = false) {
|
||||
|
||||
def write(msg: String) {
|
||||
log(msg)
|
||||
in write (msg + "\n").getBytes("UTF-8")
|
||||
in.flush()
|
||||
}
|
||||
|
||||
def destroy() {
|
||||
write("quit")
|
||||
Thread sleep 300
|
||||
process.destroy()
|
||||
}
|
||||
|
||||
private var in: OutputStream = _
|
||||
private val processIO = new ProcessIO(
|
||||
i ⇒ {
|
||||
in = i
|
||||
},
|
||||
o ⇒ fromInputStream(o).getLines foreach { line ⇒
|
||||
log(line)
|
||||
out(line)
|
||||
},
|
||||
e ⇒ fromInputStream(e).getLines foreach { line ⇒
|
||||
log(line)
|
||||
err(line)
|
||||
})
|
||||
private val process = builder run processIO
|
||||
|
||||
private def log(msg: ⇒ String) {
|
||||
if (debug) println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
object Process {
|
||||
|
||||
def apply(execPath: String)(out: String ⇒ Unit, err: String ⇒ Unit) =
|
||||
new Process(
|
||||
builder = SProcess(execPath),
|
||||
out = out,
|
||||
err = err,
|
||||
debug = true)
|
||||
}
|
||||
|
47
app/ai/stockfish/Server.scala
Normal file
47
app/ai/stockfish/Server.scala
Normal file
|
@ -0,0 +1,47 @@
|
|||
package lila
|
||||
package ai
|
||||
package stockfish
|
||||
|
||||
import chess.Rook
|
||||
import chess.format.UciDump
|
||||
import chess.format.Forsyth
|
||||
|
||||
import akka.util.Timeout
|
||||
import akka.util.Duration
|
||||
import akka.util.duration._
|
||||
import akka.dispatch.{ Future, Await }
|
||||
import akka.actor.{ Props, Actor, ActorRef }
|
||||
import akka.pattern.ask
|
||||
import play.api.Play.current
|
||||
import play.api.libs.concurrent._
|
||||
import scalaz.effects._
|
||||
|
||||
final class Server(execPath: String, config: Config) {
|
||||
|
||||
import model._
|
||||
|
||||
def apply(pgn: String, initialFen: Option[String], level: Int): Valid[IO[String]] =
|
||||
if (level < 1 || level > 8) "Invalid ai level".failNel
|
||||
else for {
|
||||
moves ← UciDump(pgn, initialFen)
|
||||
play = Play(moves, initialFen map chess960Fen, level)
|
||||
} yield io {
|
||||
Await.result(actor ? play mapTo manifest[BestMove], atMost)
|
||||
} map (_.move | "")
|
||||
|
||||
private def chess960Fen(fen: String) = (Forsyth << fen).fold(
|
||||
situation ⇒ fen.replace("KQkq", situation.board.pieces.toList filter {
|
||||
case (_, piece) ⇒ piece is Rook
|
||||
} sortBy {
|
||||
case (pos, _) ⇒ (pos.y, pos.x)
|
||||
} map {
|
||||
case (pos, piece) ⇒ piece.color.fold(pos.file.toUpperCase, pos.file)
|
||||
} mkString ""),
|
||||
fen)
|
||||
|
||||
private val atMost = 5 seconds
|
||||
private implicit val timeout = new Timeout(atMost)
|
||||
|
||||
private val process = Process(execPath) _
|
||||
private val actor = Akka.system.actorOf(Props(new FSM(process, config)))
|
||||
}
|
14
app/ai/stockfish/Stockfish.scala
Normal file
14
app/ai/stockfish/Stockfish.scala
Normal file
|
@ -0,0 +1,14 @@
|
|||
package lila
|
||||
package ai.stockfish
|
||||
|
||||
import model.BestMove
|
||||
import game.DbGame
|
||||
|
||||
trait Stockfish {
|
||||
|
||||
protected def applyMove(dbGame: DbGame, move: String) = for {
|
||||
bestMove ← BestMove(move.some filter ("" !=)).parse toValid "Wrong bestmove " + move
|
||||
(orig, dest) = bestMove
|
||||
result ← dbGame.toChess(orig, dest)
|
||||
} yield result
|
||||
}
|
54
app/ai/stockfish/model.scala
Normal file
54
app/ai/stockfish/model.scala
Normal file
|
@ -0,0 +1,54 @@
|
|||
package lila
|
||||
package ai.stockfish
|
||||
|
||||
import chess.Pos.posAt
|
||||
|
||||
import akka.actor.ActorRef
|
||||
|
||||
object model {
|
||||
|
||||
case class Play(moves: String, fen: Option[String], level: Int) {
|
||||
def position = "position %s moves %s".format(
|
||||
fen.fold("fen " + _, "startpos"),
|
||||
moves)
|
||||
def go(moveTime: Int ⇒ Int) = "go movetime %d" format moveTime(level)
|
||||
def chess960 = fen.isDefined
|
||||
}
|
||||
case class BestMove(move: Option[String]) {
|
||||
def parse = for {
|
||||
m ← move
|
||||
orig ← posAt(m take 2)
|
||||
dest ← posAt(m drop 2)
|
||||
} yield orig -> dest
|
||||
}
|
||||
|
||||
sealed trait State
|
||||
case object Starting extends State
|
||||
case object Ready extends State
|
||||
case object UciNewGame extends State
|
||||
case object Go extends State
|
||||
|
||||
case class Task(play: Play, ref: ActorRef)
|
||||
|
||||
sealed trait Data {
|
||||
def queue: Vector[Task]
|
||||
def enqueue(task: Task): Data
|
||||
}
|
||||
case class Todo(queue: Vector[Task]) extends Data {
|
||||
def enqueue(task: Task) = copy(queue = queue :+ task)
|
||||
def doing[A](withTask: Doing ⇒ A, without: Todo ⇒ A) =
|
||||
easierTaskInQueue.fold(
|
||||
task ⇒ withTask(Doing(task, queue.tail)),
|
||||
without(Todo(Vector.empty))
|
||||
)
|
||||
private def easierTaskInQueue = queue sortBy (_.play.level) headOption
|
||||
}
|
||||
case class Doing(current: Task, queue: Vector[Task]) extends Data {
|
||||
def enqueue(task: Task) = copy(queue = queue :+ task)
|
||||
def done = Todo(queue)
|
||||
}
|
||||
|
||||
sealed trait Stream { def text: String }
|
||||
case class Out(text: String) extends Stream
|
||||
case class Err(text: String) extends Stream
|
||||
}
|
|
@ -48,7 +48,7 @@ final class PgnDump(gameRepo: GameRepo, userRepo: UserRepo) {
|
|||
io(none))
|
||||
|
||||
def player(p: DbPlayer, u: Option[User]) = p.aiLevel.fold(
|
||||
"Crafty level " + _,
|
||||
"AI level " + _,
|
||||
u.fold(_.username, "Anonymous"))
|
||||
|
||||
def moves(game: DbGame) = (game.pgnList grouped 2).zipWithIndex map {
|
||||
|
|
|
@ -12,8 +12,9 @@ import play.api.Play.current
|
|||
object Ai extends LilaController {
|
||||
|
||||
private val craftyServer = env.ai.craftyServer
|
||||
private val stockfishServer = env.ai.stockfishServer
|
||||
|
||||
def run = Action { implicit req ⇒
|
||||
def playCrafty = Action { implicit req ⇒
|
||||
implicit val ctx = Context(req, None)
|
||||
Async {
|
||||
Akka.future {
|
||||
|
@ -26,4 +27,21 @@ object Ai extends LilaController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
def playStockfish = Action { implicit req ⇒
|
||||
implicit val ctx = Context(req, None)
|
||||
Async {
|
||||
Akka.future {
|
||||
stockfishServer(
|
||||
pgn = getOr("pgn", ""),
|
||||
initialFen = get("initialFen"),
|
||||
level = getIntOr("level", 1))
|
||||
} map { res ⇒
|
||||
res.fold(
|
||||
err ⇒ BadRequest(err.shows),
|
||||
op ⇒ Ok(op.unsafePerformIO)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -68,11 +68,11 @@ object Cron {
|
|||
env.titivate.finishByClock
|
||||
}
|
||||
|
||||
effect(10 seconds, "ai diagnose") {
|
||||
env.ai.remoteAi.diagnose
|
||||
}
|
||||
env.ai.remoteAi.diagnose.unsafePerformIO
|
||||
env.ai.client.diagnose.unsafePerformIO
|
||||
}
|
||||
effect(10 seconds, "ai diagnose") {
|
||||
env.ai.client.diagnose
|
||||
}
|
||||
|
||||
def message(freq: Duration)(to: (ActorRef, Any)) {
|
||||
Akka.system.scheduler.schedule(freq, freq.randomize(), to._1, to._2)
|
||||
|
|
|
@ -20,12 +20,14 @@ object Global extends GlobalSettings {
|
|||
else core.Cron start env
|
||||
}
|
||||
|
||||
override def onRouteRequest(req: RequestHeader): Option[Handler] = {
|
||||
env.monitor.rpsProvider.countRequest()
|
||||
env.security.firewall.requestHandler(req) orElse
|
||||
env.i18n.requestHandler(req) orElse
|
||||
override def onRouteRequest(req: RequestHeader): Option[Handler] =
|
||||
if (env.ai.isServer) super.onRouteRequest(req)
|
||||
else {
|
||||
env.monitor.rpsProvider.countRequest()
|
||||
env.security.firewall.requestHandler(req) orElse
|
||||
env.i18n.requestHandler(req) orElse
|
||||
super.onRouteRequest(req)
|
||||
}
|
||||
}
|
||||
|
||||
override def onHandlerNotFound(req: RequestHeader): Result = {
|
||||
controllers.Lobby handleNotFound req
|
||||
|
|
|
@ -2,6 +2,7 @@ package lila
|
|||
package core
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import scalaz.{ Success, Failure }
|
||||
|
||||
final class Settings(config: Config) {
|
||||
|
||||
|
@ -49,12 +50,20 @@ final class Settings(config: Config) {
|
|||
val FinisherLockTimeout = millis("memo.finisher_lock.timeout")
|
||||
|
||||
val AiChoice = getString("ai.use")
|
||||
val AiCrafty = "crafty"
|
||||
val AiStockfish = "stockfish"
|
||||
|
||||
val AiServerMode = getBoolean("ai.server")
|
||||
val AiRemoteUrl = getString("ai.remote.url")
|
||||
val AiClientMode = getBoolean("ai.client")
|
||||
|
||||
val AiCraftyExecPath = getString("ai.crafty.exec_path")
|
||||
val AiCraftyBookPath = Some(getString("ai.crafty.book_path")) filter ("" !=)
|
||||
val AiRemote = "remote"
|
||||
val AiCrafty = "crafty"
|
||||
val AiCraftyRemoteUrl = getString("ai.crafty.remote_url")
|
||||
|
||||
val AiStockfishExecPath = getString("ai.stockfish.exec_path")
|
||||
val AiStockfishRemoteUrl = getString("ai.stockfish.remote_url")
|
||||
val AiStockfishHashSize = getInt("ai.stockfish.hash_size")
|
||||
val AiStockfishAggressiveness = getInt("ai.stockfish.aggressiveness")
|
||||
|
||||
val MongoHost = getString("mongo.host")
|
||||
val MongoPort = getInt("mongo.port")
|
||||
|
@ -96,4 +105,13 @@ final class Settings(config: Config) {
|
|||
private def millis(name: String): Int = getMilliseconds(name).toInt
|
||||
|
||||
private def seconds(name: String): Int = millis(name) / 1000
|
||||
|
||||
implicit def validAny[A](a: A) = new {
|
||||
def valid(f: A ⇒ Valid[A]): A = f(a) match {
|
||||
case Success(a) ⇒ a
|
||||
case Failure(err) ⇒ throw new Invalid(err.shows)
|
||||
}
|
||||
}
|
||||
|
||||
private class Invalid(msg: String) extends Exception(msg)
|
||||
}
|
||||
|
|
|
@ -95,7 +95,7 @@ case class DbGame(
|
|||
}
|
||||
|
||||
Game(
|
||||
board = Board(pieces, toChessHistory),
|
||||
board = Board(pieces, toChessHistory, variant),
|
||||
player = Color(0 == turns % 2),
|
||||
pgnMoves = pgn,
|
||||
clock = clock,
|
||||
|
|
|
@ -48,8 +48,6 @@ case class DbPlayer(
|
|||
|
||||
def hasMoveTimes = moveTimes.size > 8
|
||||
|
||||
def eloEstimation = elo | 1200
|
||||
|
||||
def moveTimeList: List[Int] =
|
||||
moveTimes.split(" ").toList map parseIntOption flatten
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ object Featured {
|
|||
|
||||
private type Heuristic = DbGame ⇒ Float
|
||||
private val heuristicBox = box(0 to 1) _
|
||||
private val eloBox = box(1200 to 2000) _
|
||||
private val eloBox = box(1000 to 2000) _
|
||||
private val timeBox = box(60 to 300) _
|
||||
private val turnBox = box(1 to 21) _
|
||||
|
||||
|
@ -81,7 +81,7 @@ object Featured {
|
|||
progressHeuristic -> 0.5f)
|
||||
|
||||
def eloHeuristic(color: Color): Heuristic = game ⇒
|
||||
eloBox(game.player(color).eloEstimation)
|
||||
eloBox(game.player(color).elo | 1000)
|
||||
|
||||
def speedHeuristic: Heuristic = game ⇒
|
||||
1 - timeBox(game.estimateTotalTime)
|
||||
|
|
|
@ -6,6 +6,7 @@ import chess.{ Status, Variant, Color, Clock, Mode }
|
|||
import user.{ User, UserHelper }
|
||||
import http.Context
|
||||
import i18n.I18nHelper
|
||||
import ai.AiHelper
|
||||
import templating.StringHelper
|
||||
|
||||
import controllers.routes
|
||||
|
@ -13,9 +14,7 @@ import controllers.routes
|
|||
import play.api.templates.Html
|
||||
import play.api.mvc.Call
|
||||
|
||||
trait GameHelper { self: I18nHelper with UserHelper with StringHelper ⇒
|
||||
|
||||
val aiName = "Crafty A.I."
|
||||
trait GameHelper { self: I18nHelper with UserHelper with StringHelper with AiHelper ⇒
|
||||
|
||||
def variantName(variant: Variant)(implicit ctx: Context) = variant match {
|
||||
case Variant.Standard ⇒ trans.standard.str()
|
||||
|
@ -63,9 +62,7 @@ trait GameHelper { self: I18nHelper with UserHelper with StringHelper ⇒
|
|||
},
|
||||
"""<span class="user_link %s">%s</span>""".format(
|
||||
cssClass | "",
|
||||
player.aiLevel.fold(
|
||||
trans.aiNameLevelAiLevel(aiName, _),
|
||||
User.anonymous)
|
||||
player.aiLevel.fold(aiName, User.anonymous)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ final class Reporting(
|
|||
var mps = 0
|
||||
var cpu = 0
|
||||
var mongoStatus = MongoStatus.default
|
||||
var remoteAi = none[Int]
|
||||
var clientAi = none[Int]
|
||||
|
||||
var displays = 0
|
||||
|
||||
|
@ -87,7 +87,7 @@ final class Reporting(
|
|||
rps = rpsProvider.rps
|
||||
mps = mpsProvider.rps
|
||||
cpu = ((cpuStats.getCpuUsage() * 1000).round / 10.0).toInt
|
||||
remoteAi = env.ai.remoteAi.currentPing
|
||||
clientAi = env.ai.client.currentPing
|
||||
}
|
||||
} onComplete {
|
||||
case Left(a) ⇒ println("Reporting: " + a.getMessage)
|
||||
|
@ -111,7 +111,7 @@ final class Reporting(
|
|||
"load" -> loadAvg.toString.replace("0.", "."),
|
||||
"mem" -> memory,
|
||||
"cpu" -> cpu,
|
||||
"AI" -> remoteAi.isDefined.fold("1", "0")
|
||||
"AI" -> clientAi.isDefined.fold("1", "0")
|
||||
))
|
||||
|
||||
if (displays % 8 == 0) println(data.header)
|
||||
|
@ -125,7 +125,7 @@ final class Reporting(
|
|||
nbGames,
|
||||
game.nbHubs,
|
||||
loadAvg.toString,
|
||||
(remoteAi | 9999)
|
||||
(clientAi | 9999)
|
||||
) mkString " "
|
||||
|
||||
private def monitorData = List(
|
||||
|
@ -143,7 +143,7 @@ final class Reporting(
|
|||
"dbConn" -> mongoStatus.connection,
|
||||
"dbQps" -> mongoStatus.qps,
|
||||
"dbLock" -> math.round(mongoStatus.lock * 10) / 10d,
|
||||
"ai" -> (remoteAi | 9999)
|
||||
"ai" -> (clientAi | 9999)
|
||||
) map {
|
||||
case (name, value) ⇒ value + ":" + name
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ final class Hand(
|
|||
moretimeSeconds: Int) extends Handler(gameRepo) {
|
||||
|
||||
type IOValidEvents = IO[Valid[List[Event]]]
|
||||
type PlayResult = IO[Valid[(List[Event], String)]]
|
||||
|
||||
def play(
|
||||
povRef: PovRef,
|
||||
|
@ -32,7 +33,7 @@ final class Hand(
|
|||
destString: String,
|
||||
promString: Option[String] = None,
|
||||
blur: Boolean = false,
|
||||
lag: Int = 0): IO[Valid[(List[Event], String)]] = fromPov(povRef) {
|
||||
lag: Int = 0): PlayResult = fromPov(povRef) {
|
||||
case Pov(g1, color) ⇒ (for {
|
||||
g2 ← g1.validIf(g1 playableBy color, "Game not playable")
|
||||
orig ← posAt(origString) toValid "Wrong orig " + origString
|
||||
|
@ -44,27 +45,36 @@ final class Hand(
|
|||
).prefixFailuresWith(povRef + " - ").fold(
|
||||
e ⇒ io(failure(e)),
|
||||
progress ⇒ for {
|
||||
eventsAndBoard ← if (progress.game.finished) for {
|
||||
eventsAndFen ← if (progress.game.finished) for {
|
||||
_ ← gameRepo save progress
|
||||
finishEvents ← finisher.moveFinish(progress.game, color)
|
||||
events = progress.events ::: finishEvents
|
||||
fen = fenBoard(progress)
|
||||
} yield events -> fen
|
||||
} yield success(events -> fen)
|
||||
else if (progress.game.player.isAi && progress.game.playable) for {
|
||||
aiResult ← ai()(progress.game) map (_.err)
|
||||
(newChessGame, move) = aiResult
|
||||
progress2 = progress flatMap { _.update(newChessGame, move) }
|
||||
_ ← gameRepo save progress2
|
||||
finishEvents ← finisher.moveFinish(progress2.game, !color)
|
||||
events = progress2.events ::: finishEvents
|
||||
fen = fenBoard(progress2)
|
||||
} yield events -> fen
|
||||
initialFen ← progress.game.variant.standard.fold(
|
||||
io(none[String]),
|
||||
gameRepo initialFen progress.game.id)
|
||||
aiResult ← ai()(progress.game, initialFen)
|
||||
eventsAndFen ← aiResult.fold(
|
||||
err ⇒ io(failure(err)), {
|
||||
case (newChessGame, move) ⇒ for {
|
||||
progress2 ← io {
|
||||
progress flatMap { _.update(newChessGame, move) }
|
||||
}
|
||||
_ ← gameRepo save progress2
|
||||
finishEvents ← finisher.moveFinish(progress2.game, !color)
|
||||
events = progress2.events ::: finishEvents
|
||||
fen = fenBoard(progress2)
|
||||
} yield success(events -> fen)
|
||||
}): PlayResult
|
||||
} yield eventsAndFen
|
||||
else for {
|
||||
_ ← gameRepo save progress
|
||||
events = progress.events
|
||||
events = progress.events
|
||||
fen = fenBoard(progress)
|
||||
} yield events -> fen
|
||||
} yield success(eventsAndBoard)
|
||||
} yield success(events -> fen)
|
||||
} yield eventsAndFen
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -75,13 +85,13 @@ final class Hand(
|
|||
def resignForce(fullId: String): IO[Valid[List[Event]]] =
|
||||
gameRepo pov fullId flatMap { povOption ⇒
|
||||
(povOption toValid "No such game" flatMap { pov ⇒
|
||||
implicit val timeout = Timeout(100 millis)
|
||||
implicit val timeout = Timeout(1 second)
|
||||
Await.result(
|
||||
hubMaster ? round.IsGone(pov.game.id, !pov.color) map {
|
||||
case true ⇒ finisher resignForce pov
|
||||
case _ ⇒ !!("Opponent is not gone")
|
||||
},
|
||||
100 millis
|
||||
1 second
|
||||
)
|
||||
}).fold(err ⇒ io(failure(err)), _ map success)
|
||||
}
|
||||
|
@ -242,6 +252,6 @@ final class Hand(
|
|||
} toValid "cannot add moretime"
|
||||
)
|
||||
|
||||
private def fenBoard(progress: Progress) =
|
||||
private def fenBoard(progress: Progress) =
|
||||
Forsyth exportBoard progress.game.toChess.board
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ case class AiConfig(
|
|||
def >> = (variant.id, level, color.name).some
|
||||
|
||||
def game = DbGame(
|
||||
game = Game(board = Board(pieces = variant.pieces)),
|
||||
game = Game(board = Board init variant),
|
||||
ai = Some(!creatorColor -> level),
|
||||
whitePlayer = DbPlayer(
|
||||
color = ChessColor.White,
|
||||
|
|
|
@ -13,7 +13,7 @@ case object ApiConfig extends Config with GameGenerator {
|
|||
|
||||
def game = DbGame(
|
||||
game = Game(
|
||||
board = Board(pieces = variant.pieces),
|
||||
board = Board init variant,
|
||||
clock = none),
|
||||
ai = None,
|
||||
whitePlayer = DbPlayer.white,
|
||||
|
|
|
@ -17,7 +17,7 @@ case class FriendConfig(
|
|||
|
||||
def game = DbGame(
|
||||
game = Game(
|
||||
board = Board(pieces = variant.pieces),
|
||||
board = Board init variant,
|
||||
clock = makeClock),
|
||||
ai = None,
|
||||
whitePlayer = DbPlayer.white,
|
||||
|
|
|
@ -54,7 +54,7 @@ final class HookJoiner(
|
|||
|
||||
def makeGame(hook: Hook) = DbGame(
|
||||
game = Game(
|
||||
board = Board(pieces = hook.realVariant.pieces),
|
||||
board = Board init hook.realVariant,
|
||||
clock = hook.hasClock.fold(
|
||||
hook.time |@| hook.increment apply { (limit, inc) ⇒
|
||||
Clock(limit = limit, increment = inc)
|
||||
|
|
|
@ -35,7 +35,10 @@ final class Processor(
|
|||
pov2 ← game.player.isHuman.fold(
|
||||
io(pov),
|
||||
for {
|
||||
aiResult ← ai()(game) map (_.err)
|
||||
initialFen ← game.variant.standard.fold(
|
||||
io(none[String]),
|
||||
gameRepo initialFen game.id)
|
||||
aiResult ← ai()(game, initialFen) map (_.err)
|
||||
(newChessGame, move) = aiResult
|
||||
progress = game.update(newChessGame, move)
|
||||
_ ← gameRepo save progress
|
||||
|
|
|
@ -65,7 +65,7 @@ final class Rematcher(
|
|||
} yield pov.fullId -> p2.events
|
||||
|
||||
private def returnGame(pov: Pov): IO[DbGame] = for {
|
||||
board ← pov.game.variant.standard.fold(
|
||||
pieces ← pov.game.variant.standard.fold(
|
||||
io(pov.game.variant.pieces),
|
||||
gameRepo initialFen pov.game.id map { fenOption ⇒
|
||||
(fenOption flatMap Forsyth.<< map { situation ⇒
|
||||
|
@ -76,7 +76,7 @@ final class Rematcher(
|
|||
blackPlayer ← returnPlayer(pov.game, Black)
|
||||
} yield DbGame(
|
||||
game = Game(
|
||||
board = Board(board),
|
||||
board = Board(pieces, variant = pov.game.variant),
|
||||
clock = pov.game.clock map (_.reset)),
|
||||
whitePlayer = whitePlayer,
|
||||
blackPlayer = blackPlayer,
|
||||
|
|
|
@ -21,6 +21,7 @@ object Environment
|
|||
with setup.SetupHelper
|
||||
with message.MessageHelper
|
||||
with round.RoundHelper
|
||||
with ai.AiHelper
|
||||
with game.GameHelper
|
||||
with user.UserHelper
|
||||
with forum.ForumHelper
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
@ai(level: Int) = {
|
||||
<div class="username connected">
|
||||
@trans.aiNameLevelAiLevel("Crafty A.I.", level)
|
||||
@aiName(level)
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
|
@ -78,13 +78,22 @@ memo {
|
|||
username.timeout = 7 seconds
|
||||
}
|
||||
ai {
|
||||
server = false
|
||||
use = remote
|
||||
use = stockfish
|
||||
server = true
|
||||
client = true
|
||||
crafty {
|
||||
exec_path = "/usr/bin/crafty"
|
||||
book_path = "/usr/share/crafty"
|
||||
remote_url = "http://188.165.194.171:9071/ai/play/crafty"
|
||||
}
|
||||
stockfish {
|
||||
exec_path = "/usr/bin/stockfish"
|
||||
#remote_url = "http://188.165.194.171:9071/ai/play/stockfish"
|
||||
remote_url = "http://localhost:9000/ai/play/stockfish"
|
||||
#hash_size = 4096
|
||||
hash_size = 1024
|
||||
aggressiveness = 150
|
||||
}
|
||||
remote.url = "http://188.165.194.171:9071/ai"
|
||||
}
|
||||
moretime.seconds = 15 seconds
|
||||
|
||||
|
@ -117,7 +126,7 @@ logger {
|
|||
|
||||
akka {
|
||||
loglevel = INFO
|
||||
stdout-loglevel = INFO
|
||||
stdout-loglevel = DEBUG
|
||||
log-config-on-start = off
|
||||
event-handlers = ["lila.core.AkkaLogger"]
|
||||
}
|
||||
|
|
|
@ -89,7 +89,8 @@ GET /wiki controllers.Wiki.home
|
|||
GET /wiki/:slug controllers.Wiki.show(slug: String)
|
||||
|
||||
# AI
|
||||
GET /ai controllers.Ai.run
|
||||
GET /ai/play/crafty controllers.Ai.playCrafty
|
||||
GET /ai/play/stockfish controllers.Ai.playStockfish
|
||||
|
||||
# Lobby
|
||||
GET / controllers.Lobby.home
|
||||
|
|
|
@ -14,7 +14,7 @@ trait Resolvers {
|
|||
}
|
||||
|
||||
trait Dependencies {
|
||||
val scalachess = "com.github.ornicar" %% "scalachess" % "1.14"
|
||||
val scalachess = "com.github.ornicar" %% "scalachess" % "2.1"
|
||||
val scalaz = "org.scalaz" %% "scalaz-core" % "6.0.4"
|
||||
val specs2 = "org.specs2" %% "specs2" % "1.11"
|
||||
val salat = "com.novus" %% "salat-core" % "1.9-SNAPSHOT"
|
||||
|
|
|
@ -35,7 +35,7 @@ class FeaturedTest extends LilaSpec {
|
|||
}
|
||||
|
||||
val game1 = DbGame(
|
||||
game = chess.Game(),
|
||||
game = chess.Game(chess.Variant.default),
|
||||
whitePlayer = DbPlayer.white.copy(elo = 1600.some),
|
||||
blackPlayer = DbPlayer.black,
|
||||
ai = None,
|
||||
|
@ -55,7 +55,7 @@ class FeaturedTest extends LilaSpec {
|
|||
|
||||
"elo" in {
|
||||
"game1 white" in {
|
||||
eloHeuristic(chess.Color.White)(game1) must_== 0.5f
|
||||
eloHeuristic(chess.Color.White)(game1) must_== 0.6f
|
||||
}
|
||||
"game1 black" in {
|
||||
eloHeuristic(chess.Color.Black)(game1) must_== 0f
|
||||
|
@ -85,13 +85,13 @@ class FeaturedTest extends LilaSpec {
|
|||
}
|
||||
"score" in {
|
||||
"game1" in {
|
||||
score(game1) must_== 0.5f + 0f + 1f * 0.5f
|
||||
score(game1) must_== 0.6f + 0f + 1f * 0.5f
|
||||
}
|
||||
"game2" in {
|
||||
score(game2) must_== 0.5f + 0.5f + 0.5f * 0.5f
|
||||
score(game2) must_== 0.6f + 0.5f + 0.5f * 0.5f
|
||||
}
|
||||
"game3" in {
|
||||
score(game3) must_== 0.5f + 1f + 0f * 0.5f
|
||||
score(game3) must_== 0.6f + 1f + 0f * 0.5f
|
||||
}
|
||||
}
|
||||
"best" in {
|
||||
|
|
2
todo
2
todo
|
@ -24,3 +24,5 @@ style sound toggle
|
|||
show lobby chat to anon (and rated games?)
|
||||
or show empty chat
|
||||
play with a friend possible bug http://en.lichess.org/forum/lichess-feedback/play-with-a-friend-bug
|
||||
game chat box css issue http://en.lichess.org/forum/lichess-feedback/problem-with-the-chat-box#1
|
||||
show last move on miniboards
|
||||
|
|
Loading…
Reference in a new issue