feat(bot): implement bot-vs-bot harness for NNUE evaluation
This commit is contained in:
@@ -7,12 +7,16 @@ import de.nowchess.api.rules.RuleSet
|
|||||||
import de.nowchess.bot.ai.Evaluation
|
import de.nowchess.bot.ai.Evaluation
|
||||||
import de.nowchess.bot.bots.classic.EvaluationClassic
|
import de.nowchess.bot.bots.classic.EvaluationClassic
|
||||||
import de.nowchess.bot.bots.nnue.EvaluationNNUE
|
import de.nowchess.bot.bots.nnue.EvaluationNNUE
|
||||||
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
|
import de.nowchess.bot.logic.{ParallelSearch, TranspositionTable}
|
||||||
import de.nowchess.bot.util.PolyglotBook
|
import de.nowchess.bot.util.PolyglotBook
|
||||||
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
|
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
object HybridBot:
|
object HybridBot:
|
||||||
|
|
||||||
|
private def defaultThreads: Int =
|
||||||
|
sys.env.get("NNUE_SEARCH_THREADS").flatMap(_.toIntOption).filter(_ >= 1).getOrElse(1)
|
||||||
|
|
||||||
def apply(
|
def apply(
|
||||||
difficulty: BotDifficulty,
|
difficulty: BotDifficulty,
|
||||||
rules: RuleSet = DefaultRules,
|
rules: RuleSet = DefaultRules,
|
||||||
@@ -20,8 +24,10 @@ object HybridBot:
|
|||||||
nnueEvaluation: Evaluation = EvaluationNNUE,
|
nnueEvaluation: Evaluation = EvaluationNNUE,
|
||||||
classicalEvaluation: Evaluation = EvaluationClassic,
|
classicalEvaluation: Evaluation = EvaluationClassic,
|
||||||
vetoReporter: String => Unit = println(_),
|
vetoReporter: String => Unit = println(_),
|
||||||
|
searchThreads: Int = defaultThreads,
|
||||||
): Bot =
|
): Bot =
|
||||||
val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
|
// Use ParallelSearch to enable multi-threaded (SMP) search similar to NNUEBot
|
||||||
|
val search = ParallelSearch(rules, TranspositionTable(), () => classicalEvaluation, searchThreads)
|
||||||
context =>
|
context =>
|
||||||
val blockedMoves = BotMoveRepetition.blockedMoves(context)
|
val blockedMoves = BotMoveRepetition.blockedMoves(context)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package de.nowchess.bot.selfplay
|
||||||
|
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
|
import de.nowchess.api.rules.RuleSet
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.bot.BotDifficulty
|
||||||
|
import de.nowchess.bot.bots.{HybridBot, NNUEBot}
|
||||||
|
import de.nowchess.io.fen.FenExporter
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
|
import java.io.{BufferedWriter, FileWriter}
|
||||||
|
import java.nio.file.{Files, Path}
|
||||||
|
import scala.collection.mutable
|
||||||
|
import scala.util.Random
|
||||||
|
|
||||||
|
enum GameResult:
|
||||||
|
case Bot1Wins
|
||||||
|
case Bot2Wins
|
||||||
|
case Draw
|
||||||
|
|
||||||
|
/** Standalone bot-vs-bot harness. Runs two NNUEBots against each other from randomised openings and writes the
|
||||||
|
* visited positions as one FEN per line. Each bot can use different difficulty levels and weight files.
|
||||||
|
*
|
||||||
|
* Games run sequentially. Bots alternate: bot1 plays white, then bot1 plays black, alternating per game to ensure
|
||||||
|
* fair evaluation.
|
||||||
|
*/
|
||||||
|
object BotVsBotMain:
|
||||||
|
|
||||||
|
private case class Config(
|
||||||
|
games: Int = 1,
|
||||||
|
out: String = "modules/official-bots/python/data/botvbot.txt",
|
||||||
|
weights1: Option[String] = None,
|
||||||
|
weights2: Option[String] = None,
|
||||||
|
difficulty1: String = "Hard",
|
||||||
|
difficulty2: String = "Hard",
|
||||||
|
moveTimeMs: Long = 1000L,
|
||||||
|
randomPlies: Int = 8,
|
||||||
|
maxPlies: Int = 200,
|
||||||
|
seed: Long = System.nanoTime(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def main(args: Array[String]): Unit =
|
||||||
|
val config = parse(args.toList, Config())
|
||||||
|
|
||||||
|
val rules = DefaultRules
|
||||||
|
val difficulty1 = parseDifficulty(config.difficulty1)
|
||||||
|
val difficulty2 = parseDifficulty(config.difficulty2)
|
||||||
|
|
||||||
|
config.weights1.foreach(System.setProperty("nnue.weights", _))
|
||||||
|
val bot1 = HybridBot(difficulty1, rules, searchThreads = 6)
|
||||||
|
|
||||||
|
config.weights2.foreach(System.setProperty("nnue.weights", _))
|
||||||
|
val bot2 = HybridBot(difficulty2, rules)
|
||||||
|
|
||||||
|
val rng = new Random(config.seed)
|
||||||
|
val seen = mutable.HashSet.empty[String]
|
||||||
|
var bot1Wins = 0
|
||||||
|
var bot2Wins = 0
|
||||||
|
var draws = 0
|
||||||
|
|
||||||
|
Files.createDirectories(Path.of(config.out).toAbsolutePath.getParent)
|
||||||
|
val writer = new BufferedWriter(new FileWriter(config.out))
|
||||||
|
try
|
||||||
|
var game = 0
|
||||||
|
while game < config.games do
|
||||||
|
val bot1AsWhite = game % 2 == 0
|
||||||
|
val result = playGame(rules, bot1, bot2, rng, config, seen, writer, bot1AsWhite)
|
||||||
|
game += 1
|
||||||
|
|
||||||
|
result match
|
||||||
|
case Some(GameResult.Bot1Wins) =>
|
||||||
|
bot1Wins += 1
|
||||||
|
println(s"Game $game: Bot1 wins${if bot1AsWhite then " (as white)" else " (as black)"}")
|
||||||
|
case Some(GameResult.Bot2Wins) =>
|
||||||
|
bot2Wins += 1
|
||||||
|
println(s"Game $game: Bot2 wins${if !bot1AsWhite then " (as white)" else " (as black)"}")
|
||||||
|
case Some(GameResult.Draw) =>
|
||||||
|
draws += 1
|
||||||
|
println(s"Game $game: Draw")
|
||||||
|
case None => ()
|
||||||
|
|
||||||
|
if game % 25 == 0 then
|
||||||
|
writer.flush()
|
||||||
|
println(
|
||||||
|
s" Progress: $game/${config.games} | Positions: ${seen.size} | Bot1: $bot1Wins, Bot2: $bot2Wins, Draws: $draws\n"
|
||||||
|
)
|
||||||
|
finally writer.close()
|
||||||
|
println(s"\nFinal Statistics:")
|
||||||
|
println(s" Bot1 wins: $bot1Wins")
|
||||||
|
println(s" Bot2 wins: $bot2Wins")
|
||||||
|
println(s" Draws: $draws")
|
||||||
|
println(s" Positions: ${seen.size} -> ${config.out}")
|
||||||
|
|
||||||
|
private def playGame(
|
||||||
|
rules: RuleSet,
|
||||||
|
bot1: GameContext => Option[Move],
|
||||||
|
bot2: GameContext => Option[Move],
|
||||||
|
rng: Random,
|
||||||
|
config: Config,
|
||||||
|
seen: mutable.HashSet[String],
|
||||||
|
writer: BufferedWriter,
|
||||||
|
bot1AsWhite: Boolean,
|
||||||
|
): Option[GameResult] =
|
||||||
|
randomOpening(rules, rng, config.randomPlies, GameContext.initial) match
|
||||||
|
case None => None
|
||||||
|
case Some(start) =>
|
||||||
|
var ctx = start
|
||||||
|
var plies = config.randomPlies
|
||||||
|
var live = true
|
||||||
|
while live && plies < config.maxPlies do
|
||||||
|
if isTerminal(rules, ctx) then live = false
|
||||||
|
else
|
||||||
|
val currentBot = if (plies - config.randomPlies) % 2 == 0 then
|
||||||
|
if bot1AsWhite then bot1 else bot2
|
||||||
|
else if bot1AsWhite then bot2
|
||||||
|
else bot1
|
||||||
|
|
||||||
|
currentBot(ctx) match
|
||||||
|
case None => live = false
|
||||||
|
case Some(move) =>
|
||||||
|
ctx = rules.applyMove(ctx)(move)
|
||||||
|
plies += 1
|
||||||
|
record(rules, ctx, seen, writer)
|
||||||
|
|
||||||
|
determineWinner(rules, ctx, bot1AsWhite)
|
||||||
|
|
||||||
|
private def determineWinner(rules: RuleSet, ctx: GameContext, bot1AsWhite: Boolean): Option[GameResult] =
|
||||||
|
val legalMoves = rules.allLegalMoves(ctx)
|
||||||
|
|
||||||
|
if legalMoves.nonEmpty then
|
||||||
|
Some(GameResult.Draw)
|
||||||
|
else if rules.isCheck(ctx) then
|
||||||
|
if ctx.turn == (if bot1AsWhite then Color.Black else Color.White) then
|
||||||
|
Some(GameResult.Bot1Wins)
|
||||||
|
else
|
||||||
|
Some(GameResult.Bot2Wins)
|
||||||
|
else if rules.isFiftyMoveRule(ctx) || rules.isThreefoldRepetition(ctx) || rules.isInsufficientMaterial(ctx) then
|
||||||
|
Some(GameResult.Draw)
|
||||||
|
else
|
||||||
|
Some(GameResult.Draw)
|
||||||
|
|
||||||
|
private def randomOpening(rules: RuleSet, rng: Random, plies: Int, start: GameContext): Option[GameContext] =
|
||||||
|
var ctx = start
|
||||||
|
var i = 0
|
||||||
|
while i < plies do
|
||||||
|
val legal = rules.allLegalMoves(ctx)
|
||||||
|
if legal.isEmpty then return None
|
||||||
|
ctx = rules.applyMove(ctx)(legal(rng.nextInt(legal.size)))
|
||||||
|
i += 1
|
||||||
|
Some(ctx)
|
||||||
|
|
||||||
|
private def record(rules: RuleSet, ctx: GameContext, seen: mutable.HashSet[String], writer: BufferedWriter): Unit =
|
||||||
|
if !rules.isCheck(ctx) && !isTerminal(rules, ctx) then
|
||||||
|
val fen = FenExporter.gameContextToFen(ctx)
|
||||||
|
if seen.add(fen) then
|
||||||
|
writer.write(fen)
|
||||||
|
writer.newLine()
|
||||||
|
|
||||||
|
private def isTerminal(rules: RuleSet, ctx: GameContext): Boolean =
|
||||||
|
rules.allLegalMoves(ctx).isEmpty ||
|
||||||
|
rules.isInsufficientMaterial(ctx) ||
|
||||||
|
rules.isFiftyMoveRule(ctx) ||
|
||||||
|
rules.isThreefoldRepetition(ctx)
|
||||||
|
|
||||||
|
private def parse(args: List[String], acc: Config): Config = args match
|
||||||
|
case "--games" :: v :: rest => parse(rest, acc.copy(games = v.toInt))
|
||||||
|
case "--out" :: v :: rest => parse(rest, acc.copy(out = v))
|
||||||
|
case "--weights1" :: v :: rest => parse(rest, acc.copy(weights1 = Some(v)))
|
||||||
|
case "--weights2" :: v :: rest => parse(rest, acc.copy(weights2 = Some(v)))
|
||||||
|
case "--difficulty1" :: v :: rest => parse(rest, acc.copy(difficulty1 = v))
|
||||||
|
case "--difficulty2" :: v :: rest => parse(rest, acc.copy(difficulty2 = v))
|
||||||
|
case "--move-ms" :: v :: rest => parse(rest, acc.copy(moveTimeMs = v.toLong))
|
||||||
|
case "--random-plies" :: v :: rest => parse(rest, acc.copy(randomPlies = v.toInt))
|
||||||
|
case "--max-plies" :: v :: rest => parse(rest, acc.copy(maxPlies = v.toInt))
|
||||||
|
case "--seed" :: v :: rest => parse(rest, acc.copy(seed = v.toLong))
|
||||||
|
case Nil => acc
|
||||||
|
case unknown :: rest => println(s"Ignoring unknown arg: $unknown"); parse(rest, acc)
|
||||||
|
|
||||||
|
private def parseDifficulty(name: String): BotDifficulty = name match
|
||||||
|
case "Easy" => BotDifficulty.Easy
|
||||||
|
case "Medium" => BotDifficulty.Medium
|
||||||
|
case "Expert" => BotDifficulty.Expert
|
||||||
|
case _ => BotDifficulty.Hard
|
||||||
Reference in New Issue
Block a user