44f376f032
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #80
138 lines
6.8 KiB
Scala
138 lines
6.8 KiB
Scala
package de.nowchess.bot
|
|
|
|
import de.nowchess.api.board.{File, Rank, Square}
|
|
import de.nowchess.api.game.GameContext
|
|
import de.nowchess.api.move.{Move, MoveType}
|
|
import de.nowchess.bot.ai.Evaluation
|
|
import de.nowchess.bot.bots.HybridBot
|
|
import de.nowchess.bot.util.{PolyglotBook, PolyglotHash}
|
|
import de.nowchess.api.rules.RuleSet
|
|
import org.scalatest.funsuite.AnyFunSuite
|
|
import org.scalatest.matchers.should.Matchers
|
|
|
|
import java.io.{DataOutputStream, FileOutputStream}
|
|
import java.nio.file.Files
|
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
import scala.util.Using
|
|
|
|
class HybridBotTest extends AnyFunSuite with Matchers:
|
|
|
|
test("HybridBot apply returns a move on the initial position"):
|
|
val bot = HybridBot(BotDifficulty.Easy)
|
|
val move = bot.apply(GameContext.initial)
|
|
move should not be None
|
|
|
|
test("HybridBot apply returns None when no legal moves"):
|
|
val noMovesRules = new RuleSet:
|
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
|
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
|
|
def allLegalMoves(context: GameContext): List[Move] = Nil
|
|
def isCheck(context: GameContext): Boolean = false
|
|
def isCheckmate(context: GameContext): Boolean = true
|
|
def isStalemate(context: GameContext): Boolean = false
|
|
def isInsufficientMaterial(context: GameContext): Boolean = false
|
|
def isFiftyMoveRule(context: GameContext): Boolean = false
|
|
def isThreefoldRepetition(context: GameContext): Boolean = false
|
|
def applyMove(context: GameContext)(move: Move): GameContext = context
|
|
val bot = HybridBot(BotDifficulty.Easy, noMovesRules)
|
|
val move = bot.apply(GameContext.initial)
|
|
move should be(None)
|
|
|
|
test("HybridBot with empty book falls through to search"):
|
|
val emptyBook = PolyglotBook("/nonexistent/book.bin")
|
|
val bot = HybridBot(BotDifficulty.Easy, book = Some(emptyBook))
|
|
val move = bot.apply(GameContext.initial)
|
|
move should not be None
|
|
|
|
test("HybridBot skips move repeated three times"):
|
|
val repeatedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
|
|
val onlyMoveRules = new RuleSet:
|
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
|
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
|
|
def allLegalMoves(context: GameContext): List[Move] = List(repeatedMove)
|
|
def isCheck(context: GameContext): Boolean = false
|
|
def isCheckmate(context: GameContext): Boolean = false
|
|
def isStalemate(context: GameContext): Boolean = false
|
|
def isInsufficientMaterial(context: GameContext): Boolean = false
|
|
def isFiftyMoveRule(context: GameContext): Boolean = false
|
|
def isThreefoldRepetition(context: GameContext): Boolean = false
|
|
def applyMove(context: GameContext)(move: Move): GameContext = context
|
|
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
|
|
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
|
|
bot.apply(ctx) should be(None)
|
|
|
|
test("HybridBot uses book move when available"):
|
|
val tempFile = Files.createTempFile("hybrid_book", ".bin")
|
|
try
|
|
val ctx = GameContext.initial
|
|
val hash = PolyglotHash.hash(ctx)
|
|
val e2e4: Short = (4 | (3 << 3) | (4 << 6) | (1 << 9)).toShort
|
|
|
|
Using(DataOutputStream(FileOutputStream(tempFile.toFile))) { dos =>
|
|
dos.writeLong(hash)
|
|
dos.writeShort(e2e4)
|
|
dos.writeShort(100)
|
|
dos.writeInt(0)
|
|
}.get
|
|
|
|
val book = PolyglotBook(tempFile.toString)
|
|
val bot = HybridBot(BotDifficulty.Easy, book = Some(book))
|
|
bot.apply(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
|
finally Files.deleteIfExists(tempFile)
|
|
|
|
// Classical search picks mateMove (delivers mate); NNUE distrusts it and prefers altMove.
|
|
private val mateMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())
|
|
private val altMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
|
|
|
private def vetoRules: RuleSet = new RuleSet:
|
|
private def fresh(ctx: GameContext): Boolean = ctx.moves.isEmpty
|
|
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
|
|
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
|
|
def allLegalMoves(context: GameContext): List[Move] =
|
|
if fresh(context) then List(mateMove, altMove) else Nil
|
|
def isCheck(context: GameContext): Boolean = false
|
|
def isCheckmate(context: GameContext): Boolean = context.moves.lastOption.contains(mateMove)
|
|
def isStalemate(context: GameContext): Boolean = context.moves.lastOption.contains(altMove)
|
|
def isInsufficientMaterial(context: GameContext): Boolean = false
|
|
def isFiftyMoveRule(context: GameContext): Boolean = false
|
|
def isThreefoldRepetition(context: GameContext): Boolean = false
|
|
def applyMove(context: GameContext)(move: Move): GameContext =
|
|
context.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
|
|
|
// NNUE rates the mate move worse for us (higher = better for opponent) than the alternative.
|
|
private object DistrustfulNnue extends Evaluation:
|
|
val CHECKMATE_SCORE: Int = 10_000_000
|
|
val DRAW_SCORE: Int = 0
|
|
def evaluate(context: GameContext): Int = if context.moves.lastOption.contains(mateMove) then 5_000 else 0
|
|
|
|
private object HighClassic extends Evaluation:
|
|
val CHECKMATE_SCORE: Int = 10_000_000
|
|
val DRAW_SCORE: Int = 0
|
|
def evaluate(context: GameContext): Int = if context.moves.lastOption.contains(mateMove) then 10_000 else 0
|
|
|
|
test("HybridBot switches to NNUE's preferred move and reports veto when evals diverge"):
|
|
val reported = AtomicBoolean(false)
|
|
val bot = HybridBot(
|
|
BotDifficulty.Easy,
|
|
rules = vetoRules,
|
|
nnueEvaluation = DistrustfulNnue,
|
|
classicalEvaluation = HighClassic,
|
|
vetoReporter = _ => reported.set(true),
|
|
)
|
|
|
|
bot.apply(GameContext.initial) should be(Some(altMove))
|
|
reported.get should be(true)
|
|
|
|
test("HybridBot default veto reporter prints when threshold is exceeded"):
|
|
val bot = HybridBot(
|
|
BotDifficulty.Easy,
|
|
rules = vetoRules,
|
|
nnueEvaluation = DistrustfulNnue,
|
|
classicalEvaluation = HighClassic,
|
|
)
|
|
|
|
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
|
|
bot.apply(GameContext.initial)
|
|
}
|
|
printed should be(Some(altMove))
|