diff --git a/include/ironphoenix/tuning.hpp b/include/ironphoenix/tuning.hpp index e2cbd21..5286eb5 100644 --- a/include/ironphoenix/tuning.hpp +++ b/include/ironphoenix/tuning.hpp @@ -34,6 +34,7 @@ namespace ironphoenix { int bishopMobility = 4; int rookMobility = 2; int queenMobility = 1; + int queenInfiltration = 2; }; [[nodiscard]] SearchParameters& search() noexcept; diff --git a/src/eval.cpp b/src/eval.cpp index 9166ada..01cda66 100644 --- a/src/eval.cpp +++ b/src/eval.cpp @@ -5,6 +5,9 @@ #include "ironphoenix/position.hpp" #include "ironphoenix/tuning.hpp" +#include +#include + namespace ironphoenix::Eval { namespace { @@ -92,9 +95,82 @@ namespace ironphoenix::Eval { return score; } + // Distance past the board's central boundary towards an enemy home edge. + // The four home edges rotate, so ordinary two-player rank advancement + // cannot describe queen infiltration on a cross-shaped 4PC board. + IRONPHOENIX_FORCE_INLINE int enemyCampDepth(Color enemyColor, int file, int rank) noexcept { + switch (enemyColor) { + case RED: return 7 - rank; + case BLUE: return 7 - file; + case YELLOW: return rank - 6; + case GREEN: return file - 6; + } + return 0; + } + + IRONPHOENIX_FORCE_INLINE bool inHomeCamp(Color color, int file, int rank) noexcept { + switch (color) { + case RED: return rank <= 2; + case BLUE: return file <= 2; + case YELLOW: return rank >= 11; + case GREEN: return file >= 11; + } + return false; + } + + int queenInfiltrationForColor(const Position& pos, Color c) noexcept { + const int weight = Tuning::eval().queenInfiltration; + if (weight == 0) + return 0; + + int score = 0; + Bitboard queens = pos.pieces(c, QUEEN); + const Bitboard occupancy = pos.occupancy(); + + while (queens) { + const Square sq = popLsb(queens); + const int file = static_cast(Geometry::fileOf(sq)); + const int rank = static_cast(Geometry::rankOf(sq)); + + // Do not reward a queen that has not left its starting camp, + // or one that the opposing team can capture immediately. + if (inHomeCamp(c, file, rank) + || pos.isAttackedByEnemy(sq, c, occupancy)) + continue; + + for (unsigned ci = 0; ci < COLOR_NB; ++ci) { + const Color opponent = static_cast(ci); + if (!enemy(c, opponent) || !pos.isAlive(opponent)) + continue; + + const Square king = pos.kingSquare(opponent); + if (king == SQ_NONE) + continue; + + // A shallow central queen is not an infiltration. + const int depth = enemyCampDepth(opponent, file, rank); + if (depth < 2) + continue; + + const int kingFile = static_cast(Geometry::fileOf(king)); + const int kingRank = static_cast(Geometry::rankOf(king)); + const int distance = std::max(std::abs(file - kingFile), std::abs(rank - kingRank)); + if (distance > 6) + continue; + + // More credit for penetrating deeper while staying close + // enough to an enemy king to create threats. + score += (depth - 1) * (7 - distance) * weight; + } + } + + return score; + } + int playerScore(const Position& pos, Color c) noexcept { return materialForColor(pos, c) - + mobilityForColor(pos, c); + + mobilityForColor(pos, c) + + queenInfiltrationForColor(pos, c); } int handcraftedEvaluate(const Position& pos) noexcept { diff --git a/src/tuning.cpp b/src/tuning.cpp index bffd563..372ac05 100644 --- a/src/tuning.cpp +++ b/src/tuning.cpp @@ -58,7 +58,8 @@ namespace ironphoenix::Tuning { || name == "EvalKnightMobility" || name == "EvalBishopMobility" || name == "EvalRookMobility" - || name == "EvalQueenMobility"; + || name == "EvalQueenMobility" + || name == "EvalQueenInfiltration"; } bool isLmrParameter(std::string_view name) noexcept { @@ -155,6 +156,8 @@ namespace ironphoenix::Tuning { p.rookMobility = value; else if (name == "EvalQueenMobility" && inRange(value, -100, 100)) p.queenMobility = value; + else if (name == "EvalQueenInfiltration" && inRange(value, 0, 20)) + p.queenInfiltration = value; else return false; @@ -228,6 +231,7 @@ namespace ironphoenix::Tuning { << "option name EvalBishopMobility type spin default 4 min -100 max 100\n" << "option name EvalRookMobility type spin default 2 min -100 max 100\n" << "option name EvalQueenMobility type spin default 1 min -100 max 100\n" + << "option name EvalQueenInfiltration type spin default 2 min 0 max 20\n" << "option name ResetTuning type button\n"; } @@ -256,7 +260,8 @@ namespace ironphoenix::Tuning { << " EvalKnightMobility=" << e.knightMobility << " EvalBishopMobility=" << e.bishopMobility << " EvalRookMobility=" << e.rookMobility - << " EvalQueenMobility=" << e.queenMobility << '\n'; + << " EvalQueenMobility=" << e.queenMobility + << " EvalQueenInfiltration=" << e.queenInfiltration << '\n'; } } // namespace ironphoenix::Tuning diff --git a/tests/smoke.cpp b/tests/smoke.cpp index 400ef1f..1092bb4 100644 --- a/tests/smoke.cpp +++ b/tests/smoke.cpp @@ -1,3 +1,4 @@ +#include "ironphoenix/eval.hpp" #include "ironphoenix/geometry.hpp" #include "ironphoenix/position.hpp" #include "ironphoenix/see.hpp" @@ -18,6 +19,26 @@ static void putKings(Position& p) { p.placePiece(sq('n', 7), makePiece(GREEN, KING)); } +// Measure only the new queen term, holding material and mobility constant. +static int queenInfiltrationDelta(Color queenColor, char file, int rank, bool threatened = false) { + Position p; + p.clear(); + putKings(p); + p.placePiece(sq(file, rank), makePiece(queenColor, QUEEN)); + if (threatened) + p.placePiece(sq('d', 9), makePiece(BLUE, ROOK)); + + p.setSideToMove(queenColor); + p.finalizeSetup(); + + auto& params = Eval::parameters(); + const int saved = params.queenInfiltration; + params.queenInfiltration = 0; + const int baseline = Eval::evaluate(p); + params.queenInfiltration = saved; + return Eval::evaluate(p) - baseline; +} + int main() { Geometry::init(); @@ -99,6 +120,29 @@ int main() { assert(Geometry::isPromotionSquare(YELLOW, sq('g', 4))); assert(Geometry::isPromotionSquare(GREEN, sq('d', 7))); + // Do not hide these checks behind assert: CI runs Release with NDEBUG. + Tuning::resetEval(); + const bool allOrientations = + queenInfiltrationDelta(RED, 'd', 7) > 0 // Blue home edge + && queenInfiltrationDelta(YELLOW, 'k', 7) > 0 // Green home edge + && queenInfiltrationDelta(BLUE, 'g', 4) > 0 // Red home edge + && queenInfiltrationDelta(GREEN, 'g', 11) > 0; // Yellow home edge + + // No reward for own/allied territory or an enemy-attacked queen. + const bool safeOnly = + queenInfiltrationDelta(RED, 'g', 4) == 0 + && queenInfiltrationDelta(RED, 'g', 11) == 0 + && queenInfiltrationDelta(RED, 'd', 7, true) == 0; + + const bool tunable = Tuning::setEvalParameter("EvalQueenInfiltration", 0) + && queenInfiltrationDelta(RED, 'd', 7) == 0; + Tuning::resetEval(); + + if (!allOrientations || !safeOnly || !tunable) { + std::cerr << "queen infiltration evaluation tests: FAIL\n"; + return 1; + } + std::cout << "nexus_fast_board smoke tests: PASS\n"; return 0; }