unify play and analyse stockfish FSM actors

This commit is contained in:
Thibault Duplessis 2012-07-22 18:27:28 +02:00
parent 2c0cb59309
commit 5c5300bfec
15 changed files with 242 additions and 367 deletions

View file

@ -25,11 +25,9 @@ final class AiEnv(settings: Settings) {
lazy val stockfishServer = new stockfish.Server(
execPath = AiStockfishExecPath,
playConfig = stockfishPlayConfig,
analyseConfig = stockfishAnalyseConfig)
config = stockfishConfig)
lazy val stockfishPlayConfig = new stockfish.PlayConfig(settings)
lazy val stockfishAnalyseConfig = new stockfish.AnalyseConfig(settings)
lazy val stockfishConfig = new stockfish.Config(settings)
lazy val stupidAi = new StupidAi

View file

@ -0,0 +1,101 @@
package lila
package ai.stockfish
import model._
import model.analyse._
import akka.actor.{ Props, Actor, ActorRef, FSM AkkaFSM, LoggingFSM }
import scalaz.effects._
final class ActorFSM(
processBuilder: Process.Builder,
config: Config)
extends Actor with LoggingFSM[State, Data] {
var process: Process = _
override def preStart() {
process = processBuilder(
out self ! Out(out),
err self ! Err(err),
msg !isNoise(msg))
}
startWith(Starting, Todo())
when(Starting) {
case Event(Out(t), _) if t startsWith "Stockfish" {
process write "uci"
stay
}
case Event(Out("uciok"), data) {
config.init foreach process.write
nextTask(data)
}
case Event(task: analyse.Task.Builder, data)
stay using (data enqueue task(sender))
case Event(task: play.Task.Builder, data)
stay using (data enqueue task(sender))
}
when(Idle) {
case Event(Out(t), _) { log.warning(t); stay }
}
when(IsReady) {
case Event(Out("readyok"), doing: Doing) {
config go doing.current foreach process.write
goto(Running)
}
}
when(Running) {
case Event(Out(t), doing: Doing) if t startsWith "info depth"
stay using (doing map (_.right map (_ buffer t)))
case Event(Out(t), doing: Doing) if t startsWith "bestmove"
doing.current.fold(
play {
play.ref ! model.play.BestMove(t.split(' ') lift 1)
nextTask(doing.done)
},
anal (anal buffer t).flush.fold(
err {
log error err.shows
anal.ref ! failure(err)
nextTask(doing.done)
},
task task.isDone.fold({
task.ref ! success(task.analysis.done)
nextTask(doing.done)
},
nextTask(doing.done enqueue task)
)
)
)
}
whenUnhandled {
case Event(task: analyse.Task.Builder, data) nextTask(data enqueue task(sender))
case Event(task: play.Task.Builder, data) nextTask(data enqueue task(sender))
case Event(GetQueueSize, data) sender ! QueueSize(data.size); stay
case Event(Out(t), _) if isNoise(t) stay
case Event(Out(t), _) { log.warning(t); stay }
case Event(Err(t), _) { log.error(t); stay }
case Event(e @ RebootException, _) throw e
}
def nextTask(data: Data) = data.fold(
todo todo.doing(
doing {
config prepare doing.current foreach process.write
goto(IsReady) using doing
},
todo goto(Idle) using todo
),
doing stay using doing
)
def isNoise(t: String) =
t.isEmpty || (t startsWith "id ") || (t startsWith "info ") || (t startsWith "option name ")
override def postStop() {
process.destroy()
process = null
}
}

View file

@ -1,24 +0,0 @@
package lila
package ai.stockfish
import model._
import model.analyse._
import core.Settings
final class AnalyseConfig(settings: Settings) extends Config {
type Instructions = List[String]
def init: Instructions = List(
setoption("Uci_AnalyseMode", true),
setoption("Hash", settings.AiStockfishAnalyseHashSize),
setoption("Threads", 8),
setoption("Ponder", false)
)
def game(analyse: Analyse): Instructions = List(
setoption("UCI_Chess960", analyse.chess960)
)
def moveTime = settings.AiStockfishAnalyseMoveTime
}

View file

@ -1,99 +0,0 @@
package lila
package ai.stockfish
import model._
import model.analyse._
import akka.actor.{ Props, Actor, ActorRef, FSM AkkaFSM, LoggingFSM }
import scalaz.effects._
final class AnalyseFSM(
processBuilder: Process.Builder,
config: AnalyseConfig)
extends Actor with LoggingFSM[State, Data] {
var process: Process = _
override def preStart() {
process = processBuilder(
out self ! Out(out),
err self ! Err(err),
msg !isNoise(msg))
}
startWith(Starting, Todo())
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
nextAnalyse(data)
}
case Event(analyse: Analyse, data)
stay using (data enqueue Task(analyse, sender))
}
when(Ready) {
case Event(Out(t), _) { log.warning(t); stay }
}
when(UciNewGame) {
case Event(Out(t), data: Doing) if t contains "readyok"
nextInfo(data)
}
when(Running) {
case Event(Out(t), data: Doing) if t startsWith "info depth" {
goto(Running) using (data buffer t)
}
case Event(Out(t), data: Doing) if t contains "bestmove"
(data buffer t).flush.fold(
err {
log.error(err.shows)
data.current.ref ! failure(err)
nextAnalyse(data.done)
},
nextData nextInfo(nextData)
)
}
whenUnhandled {
case Event(analyse: Analyse, data) nextAnalyse(data enqueue Task(analyse, sender))
case Event(GetQueueSize, data) sender ! QueueSize(data.size); stay
case Event(Out(t), _) if isNoise(t) stay
case Event(Out(t), _) { log.warning(t); stay }
case Event(Err(t), _) { log.error(t); stay }
case Event(e @ RebootException, _) throw e
}
def nextAnalyse(data: Data) = data match {
case todo: Todo todo.doing(
doing {
config game doing.current.analyse 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 nextInfo(doing: Doing) = doing.current |> { task
(task.analyse go config.moveTime).fold(
instructions {
instructions foreach process.write
goto(Running) using doing
}, {
task.ref ! success(task.analyse.analysis.done)
nextAnalyse(doing.done)
})
}
def isNoise(t: String) =
t.isEmpty || (t startsWith "id ") || (t startsWith "info ") || (t startsWith "option name ")
override def postStop() {
process.destroy()
process = null
}
}

View file

@ -1,9 +1,58 @@
package lila
package ai.stockfish
trait Config {
import model._
import core.Settings
protected def setoption(name: String, value: Any) =
final class Config(settings: Settings) {
import Config._
def moveTime(level: Int) = (levelBox(level) * playMaxMoveTime) / levels.end
def ownBook(level: Int) = levelBox(level) > 4
def skill(level: Int) = math.round((levelBox(level) - 1) * (skillMax / 7f))
def depth(level: Int) = levelBox(level)
def init = List(
setoption("Hash", settings.AiStockfishHashSize),
setoption("Threads", settings.AiStockfishThreads),
setoption("Ponder", false))
def prepare(task: Task) = task.fold(
play List(
setoption("Uci_AnalyseMode", false),
setoption("Skill Level", skill(play.level)),
setoption("UCI_Chess960", play.chess960),
setoption("OwnBook", ownBook(play.level)),
"ucinewgame",
"isready"),
anal List(
setoption("Uci_AnalyseMode", true),
setoption("Skill Level", skillMax),
setoption("UCI_Chess960", anal.chess960),
setoption("OwnBook", true),
"ucinewgame",
"isready"))
def go(task: Task) = task.fold(
play List(
position(play.fen, play.moves),
"go movetime %d depth %d".format(moveTime(play.level), depth(play.level))),
anal List(
position(anal.fen, anal.pastMoves),
"go movetime %d".format(analyseMoveTime)))
private def playMaxMoveTime = settings.AiStockfishPlayMaxMoveTime
private def analyseMoveTime = settings.AiStockfishAnalyseMoveTime
private def position(fen: Option[String], moves: String) =
"position %s moves %s".format(fen.fold("fen " + _, "startpos"), moves)
private def setoption(name: String, value: Any) =
"setoption name %s value %s".format(name, value)
}
@ -12,4 +61,6 @@ object Config {
val levels = 1 to 8
val levelBox = intBox(1 to 8) _
val skillMax = 20
}

View file

@ -1,42 +0,0 @@
package lila
package ai.stockfish
import model._
import model.play._
import core.Settings
final class PlayConfig(settings: Settings) extends Config {
import Config._
def moveTime(level: Int) = (levelBox(level) * maxMoveTime) / levels.end
def ownBook(level: Int) = levelBox(level) > 4
def skill(level: Int) = math.round((levelBox(level) -1) * (20 / 7f))
def depth(level: Int) = levelBox(level)
def init = List(
setoption("Hash", settings.AiStockfishPlayHashSize),
setoption("Threads", 8),
setoption("Ponder", false),
setoption("Aggressiveness", settings.AiStockfishPlayAggressiveness),
setoption("Cowardice", settings.AiStockfishPlayCowardice)
)
def game(play: Play) = List(
setoption("Skill Level", skill(play.level)),
setoption("UCI_Chess960", play.chess960),
setoption("OwnBook", ownBook(play.level)),
"ucinewgame",
"isready"
)
def move(fen: Option[String], moves: String, level: Int) = List(
"position %s moves %s".format(fen.fold("fen " + _, "startpos"), moves),
"go movetime %d depth %d".format(moveTime(level), depth(level))
)
private def maxMoveTime = settings.AiStockfishPlayMaxMoveTime
}

View file

@ -1,79 +0,0 @@
package lila
package ai.stockfish
import model._
import model.play._
import akka.actor.{ Props, Actor, ActorRef, FSM AkkaFSM }
import scalaz.effects._
final class PlayFSM(
processBuilder: Process.Builder,
config: PlayConfig)
extends Actor with AkkaFSM[State, Data] {
var process: Process = _
override def preStart() {
process = processBuilder(
out self ! Out(out),
err self ! Err(err),
msg !isNoise(msg))
}
startWith(Starting, Todo())
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), Doing(Task(play, _), _)) if t contains "readyok" {
play.go foreach process.write
goto(Running)
}
}
when(Running) {
case Event(Out(t), doing: Doing) if t contains "bestmove" {
doing.current.ref ! BestMove(t.split(' ') lift 1)
next(doing.done)
}
}
whenUnhandled {
case Event(play: Play, data) next(data enqueue Task(play, sender))
case Event(GetQueueSize, data) sender ! QueueSize(data.size); stay
case Event(Out(t), _) if isNoise(t) stay
case Event(Err(t), _) { log.error(t); stay }
case Event(e @ RebootException, _) throw e
}
def next(data: Data) = data match {
case todo: Todo todo.doing(
doing {
config game doing.current.play foreach process.write
goto(UciNewGame) using doing
},
t goto(Ready) using t
)
case doing: Doing stay using data
}
def isNoise(t: String) =
t.isEmpty || (t startsWith "id ") || (t startsWith "info ") || (t startsWith "option name ")
override def postStop() {
process.destroy()
process = null
}
}

View file

@ -20,19 +20,18 @@ import scalaz.effects._
final class Server(
execPath: String,
playConfig: PlayConfig,
analyseConfig: AnalyseConfig) {
config: Config) {
def play(pgn: String, initialFen: Option[String], level: Int): Future[Valid[String]] = {
implicit val timeout = new Timeout(playAtMost)
(for {
moves UciDump(pgn, initialFen)
play = model.play.Play(moves, initialFen map chess960Fen, level, playConfig)
play = model.play.Task.Builder(moves, initialFen map chess960Fen, level)
} yield play).fold(
err Future(failure(err)),
play playActor ? play mapTo bestMoveManifest map { m
play actor ? play mapTo bestMoveManifest map { m
success(m.move | "")
} onFailure reboot(playActor)
} onFailure reboot
)
}
@ -40,16 +39,16 @@ final class Server(
UciDump(pgn, initialFen).fold(
err Future(failure(err)),
moves {
val analyse = model.analyse.Analyse(moves, initialFen map chess960Fen)
val analyse = model.analyse.Task.Builder(moves, initialFen map chess960Fen)
implicit val timeout = Timeout(analyseAtMost)
analyseActor ? analyse mapTo analysisManifest onFailure reboot(analyseActor)
actor ? analyse mapTo analysisManifest onFailure reboot
}
)
def report: Future[(Int, Int)] = {
def report: Future[Int] = {
implicit val timeout = new Timeout(playAtMost)
(playActor ? GetQueueSize) zip (analyseActor ? GetQueueSize) map {
case (QueueSize(play), QueueSize(analyse)) play -> analyse
actor ? GetQueueSize map {
case QueueSize(s) s
}
}
@ -63,7 +62,7 @@ final class Server(
} mkString ""),
fen)
private def reboot(actor: ActorRef): PartialFunction[Throwable, Unit] = {
private val reboot: PartialFunction[Throwable, Unit] = {
case e: AskTimeoutException actor ! model.RebootException
}
private val analysisManifest = manifest[Valid[Analysis]]
@ -72,12 +71,9 @@ final class Server(
private implicit val executor = Akka.system.dispatcher
private val playAtMost = 5 seconds
private lazy val playProcess = Process(execPath, "SFP") _
private lazy val playActor = Akka.system.actorOf(Props(
new PlayFSM(playProcess, playConfig)))
private val analyseAtMost = 10 minutes
private lazy val analyseProcess = Process(execPath, "SFA") _
private lazy val analyseActor = Akka.system.actorOf(Props(
new AnalyseFSM(analyseProcess, analyseConfig)))
private lazy val process = Process(execPath, "StockFish") _
private lazy val actor = Akka.system.actorOf(Props(
new ActorFSM(process, config)))
}

View file

@ -6,111 +6,95 @@ import chess.format.UciMove
import analyse.{ Analysis, AnalysisBuilder }
import akka.actor.ActorRef
import collection.immutable.Queue
object model {
type Task = Either[play.Task, analyse.Task]
sealed trait Data {
def queue: Vector[Task]
def enqueue(task: Task): Data
def enqueue(task: play.Task): Data = enqueue(Left(task))
def enqueue(task: analyse.Task): Data = enqueue(Right(task))
def size = queue.size
def dequeue: Option[(Task, Vector[Task])] =
queue find (_.isLeft) orElse queue.headOption map { task
task -> queue.filter(task !=)
}
def fold[A](todo: Todo A, doing: Doing A): A
override def toString = getClass.getName + " = " + queue.size
}
case class Todo(queue: Vector[Task] = Vector.empty) extends Data {
def doing[A](withTask: Doing A, without: Todo A) = dequeue.fold({
case (task, rest) withTask(Doing(task, rest))
}, without(this))
def fold[A](todo: Todo A, doing: Doing A): A = todo(this)
def enqueue(task: Task) = copy(queue :+ task)
}
case class Doing(current: Task, queue: Vector[Task]) extends Data {
def done = Todo(queue)
def fold[A](todo: Todo A, doing: Doing A): A = doing(this)
def enqueue(task: Task) = copy(queue = queue :+ task)
def map(f: Task Task): Doing = copy(current = f(current))
}
object play {
case class Play(
moves: String,
fen: Option[String],
level: Int,
config: PlayConfig) {
def go = config.move(fen, moves, level)
case class Task(
moves: String,
fen: Option[String],
level: Int,
ref: ActorRef) {
def chess960 = fen.isDefined
}
object Task {
case class Builder(moves: String, fen: Option[String], level: Int) {
def apply(sender: ActorRef) = new Task(moves, fen, level, sender)
}
}
case class BestMove(move: Option[String]) {
def parse = UciMove(move | "")
}
case class Task(play: Play, ref: ActorRef)
sealed trait Data {
def queue: Queue[Task]
def enqueue(task: Task): Data
def size = queue.size
}
case class Todo(queue: Queue[Task] = Queue.empty) extends Data {
def enqueue(task: Task) = copy(queue = queue :+ task)
def doing[A](withTask: Doing A, without: Todo A) =
queue.headOption.fold(
task withTask(Doing(task, queue.tail)),
without(Todo(Queue.empty))
)
}
case class Doing(current: Task, queue: Queue[Task]) extends Data {
def enqueue(task: Task) = copy(queue = queue :+ task)
def done = Todo(queue)
}
}
object analyse {
case class Analyse(
case class Task(
moves: IndexedSeq[String],
fen: Option[String],
analysis: AnalysisBuilder,
infoBuffer: List[String]) {
def go(moveTime: Int) = nextMove.isDefined option List(
"position %s moves %s".format(
fen.fold("fen " + _, "startpos"),
moves take analysis.size mkString " "),
"go movetime %d".format(moveTime)
)
def nextMove = moves lift analysis.size
infoBuffer: List[String],
ref: ActorRef) {
def pastMoves: String = moves take analysis.size mkString " "
def nextMove: Option[String] = moves lift analysis.size
def isDone = nextMove.isEmpty
def buffer(str: String) = copy(infoBuffer = str :: infoBuffer)
def flush = for {
move nextMove toValid "No move to flush"
info AnalyseParser(infoBuffer)(move)
} yield copy(
analysis = analysis + info,
infoBuffer = Nil)
} yield copy(analysis = analysis + info, infoBuffer = Nil)
def chess960 = fen.isDefined
}
object Analyse {
def apply(moves: String, fen: Option[String]) = new Analyse(
moves = moves.split(' ').toIndexedSeq,
fen = fen,
analysis = Analysis.builder,
infoBuffer = Nil)
}
case class Task(analyse: Analyse, ref: ActorRef) {
def buffer(str: String) = copy(analyse = analyse buffer str)
def flush = analyse.flush map { a copy(analyse = a) }
}
sealed trait Data {
def queue: Queue[Task]
def enqueue(task: Task): Data
def size = queue.size
}
case class Todo(queue: Queue[Task] = Queue.empty) extends Data {
def enqueue(task: Task) = copy(queue = queue :+ task)
def doing[A](withTask: Doing A, without: Todo A) =
queue.headOption.fold(
task withTask(Doing(task, queue.tail)),
without(Todo(Queue.empty))
)
}
case class Doing(current: Task, queue: Queue[Task]) extends Data {
def enqueue(task: Task) = copy(queue = queue :+ task)
def done = Todo(queue)
def buffer(str: String) = copy(current = current buffer str)
def flush = current.flush map { c copy(current = c) }
object Task {
case class Builder(moves: String, fen: Option[String]) {
def apply(sender: ActorRef) = new Task(
moves = moves.split(' ').toIndexedSeq,
fen = fen,
analysis = Analysis.builder,
infoBuffer = Nil,
sender)
}
}
}
sealed trait State
case object Starting extends State
case object Ready extends State
case object UciNewGame extends State
case object Idle extends State
case object IsReady extends State
case object Running extends State
sealed trait Stream { def text: String }

View file

@ -51,7 +51,7 @@ object Ai extends LilaController {
IfServer {
Async {
env.ai.stockfishServerReport.fold(
_ map { case (play, analyse) Ok("%d %d".format(play, analyse)) },
_ map { Ok(_) },
Future(NotFound)
).asPromise
}

View file

@ -79,15 +79,13 @@ final class Settings(config: Config) {
val AiClientMode = getBoolean("ai.client")
val AiStockfishExecPath = getString("ai.stockfish.exec_path")
val AiStockfishHashSize = getInt("ai.stockfish.hash_size")
val AiStockfishThreads = getInt("ai.stockfish.threads")
val AiStockfishPlayUrl = getString("ai.stockfish.play.url")
val AiStockfishPlayHashSize = getInt("ai.stockfish.play.hash_size")
val AiStockfishPlayMaxMoveTime = getInt("ai.stockfish.play.movetime")
val AiStockfishPlayAggressiveness = getInt("ai.stockfish.play.aggressiveness")
val AiStockfishPlayCowardice = getInt("ai.stockfish.play.cowardice")
val AiStockfishAnalyseUrl = getString("ai.stockfish.analyse.url")
val AiStockfishAnalyseHashSize = getInt("ai.stockfish.analyse.hash_size")
val AiStockfishAnalyseMoveTime = getInt("ai.stockfish.analyse.movetime")
val MongoHost = getString("mongo.host")

View file

@ -114,16 +114,14 @@ ai {
client = true
stockfish {
exec_path = "/usr/bin/stockfish"
hash_size = 1024
threads = 8
play {
hash_size = 1024
movetime = 400
aggressiveness = 150 # 0 - 200
cowardice = 50 # 0 - 200
url = "http://188.165.194.171:9072/ai/stockfish/play"
}
analyse {
hash_size = 1024
movetime = 300
movetime = 400
url = "http://188.165.194.171:9072/ai/stockfish/analyse"
}
}

View file

@ -5,11 +5,8 @@ ai {
server = true
client = false
stockfish {
play {
hash_size = 4096
}
hash_size = 8192
analyse {
hash_size = 4096
movetime = 500
}
}

View file

@ -3,12 +3,8 @@ include "application_ai_server"
config_name = "development AI server"
ai {
stockfish {
play {
hash_size = 64
}
analyse {
hash_size = 64
}
hash_size = 64
threads = 2
}
}

View file

@ -3,14 +3,14 @@ include "application"
config_name = "development"
ai {
server = false
client = true
client = false
stockfish {
hash_size = 64
threads = 2
play {
#url = "http://localhost:9072/ai/stockfish/play"
hash_size = 64
}
analyse {
hash_size = 64
movetime = 200
}
}