Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8cbcdca3b | |||
| e4fee85134 | |||
| b4709b4a33 | |||
| 9f9140cb58 | |||
| fa10852bc9 | |||
| 44f376f032 | |||
| 7372867a82 | |||
| c3e7b82ae8 | |||
| e88b081947 | |||
| 1b30c3be39 | |||
| f8ca95af3c | |||
| 4a50db0721 | |||
| 260db25803 | |||
| 80e1cc258b | |||
| bfc46723e6 | |||
| bace029a8a | |||
| 7664042193 | |||
| a604b4ad42 | |||
| fdf4c94811 | |||
| d9f30f0bfe | |||
| 1f4e9c8498 | |||
| e2b13c0c8f | |||
| bfb15c7299 | |||
| 627f017cdc | |||
| 10113fd057 | |||
| b57e5827df | |||
| b98bdd2a64 | |||
| 285b73efbd | |||
| 06f2adfeb6 | |||
| 4651bb796f | |||
| 1df29cf3a6 |
@@ -81,3 +81,20 @@
|
||||
|
||||
* **analytics:** upgrade Spark to 4.0.3 — 3.5.x has no official Docker image ([46af115](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/46af1154de34a8596cb6cb28c6fad7aba90f597c))
|
||||
* **analytics:** write decompressed PGN to shared PVC path for executor access ([a268a9a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a268a9acb7ba190c76e996ccf3ea3bd00e5cec92))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add 7 new Spark analytics jobs and extend GameSource ([8e17c14](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8e17c14dff740cd115011dfbf17de35083b8fe46))
|
||||
* **analytics:** add accuracy and blunder analysis job for Lichess data ([c3e7b82](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c3e7b82ae806adf5713ce4d267c1155e73a40ff5))
|
||||
* **analytics:** add Dockerfile, CI workflow, and stable jar name for K8s deployment ([95215b6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/95215b6a420fd526df1aa395f9b087556c8ad03b))
|
||||
* **analytics:** add PostgreSQL JDBC write-back to all four batch jobs ([0e0ea4c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/0e0ea4c9893c6efed52e633e55d05ab3ed004502))
|
||||
* **analytics:** add Spark batch analytics module ([259b3bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/259b3bbb24c0f23326269b93f4b3c84012f727cd))
|
||||
* **analytics:** add Structured Streaming, MLlib clustering, GraphX jobs ([e1d80b9](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e1d80b9331666feea191b1fd08aa762f3581c918))
|
||||
* **analytics:** always write results to PostgreSQL regardless of input source ([da0e6d1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/da0e6d1ee2d391ecb6291396f82471eb51b1b25e))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#76](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/76)) ([751a58b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/751a58b6061f7434115e229a7661894c76768bc2))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **analytics:** upgrade Spark to 4.0.3 — 3.5.x has no official Docker image ([46af115](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/46af1154de34a8596cb6cb28c6fad7aba90f597c))
|
||||
* **analytics:** write decompressed PGN to shared PVC path for executor access ([a268a9a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a268a9acb7ba190c76e996ccf3ea3bd00e5cec92))
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package de.nowchess.analytics
|
||||
|
||||
import org.apache.spark.sql.SparkSession
|
||||
import org.apache.spark.sql.expressions.Window
|
||||
import org.apache.spark.sql.functions as F
|
||||
|
||||
/** Per-move accuracy & blunder analysis mined from Lichess `[%eval ...]` move annotations.
|
||||
*
|
||||
* Unlike the flat single-`groupBy` summaries (opening rates, colour advantage), this job reconstructs the *quality of
|
||||
* every move* from the engine evaluations Lichess embeds in the movetext (`{ [%eval 0.24] }`, mate scores `[%eval
|
||||
* #-3]`) and turns them into the same accuracy signals lichess.com surfaces: average centipawn loss (ACPL), and counts
|
||||
* of inaccuracies / mistakes / blunders.
|
||||
*
|
||||
* Pipeline (all Spark SQL string/array functions + window funcs — no UDFs, Catalyst-friendly):
|
||||
* 1. Keep only games carrying `[%eval` comments.
|
||||
* 2. `regexp_extract_all` pulls every eval in ply order; mate scores collapse to ±10 pawns, normal evals are clamped
|
||||
* to ±10 so a single huge swing cannot dominate the mean. All evals are White-POV pawns.
|
||||
* 3. `posexplode` → one row per ply; a per-game window `lag` gives the eval *before* the move.
|
||||
* 4. Centipawn loss for the side that moved = how much the eval moved against them (white wants it up, black down),
|
||||
* floored at 0 and scaled to centipawns.
|
||||
* 5. Roll up to (game, side): ACPL + inaccuracy(≥50cp) / mistake(≥100cp) / blunder(≥200cp) counts, tagged with that
|
||||
* side's Elo and whether they won.
|
||||
*
|
||||
* Outputs (Parquet + CSV + JDBC):
|
||||
* - `accuracy_by_rating` — ACPL, avg blunders/mistakes/inaccuracies per game and win-rate, per Elo band. Shows how
|
||||
* move quality scales with rating.
|
||||
* - `blunder_outcome` — win-rate bucketed by number of blunders in the game. Quantifies "one blunder costs you the
|
||||
* game".
|
||||
*
|
||||
* Requires the eval-annotated Lichess dump (`NOWCHESS_PGN_PATH` → an evals dump); JDBC games carry no per-move evals.
|
||||
*/
|
||||
object AccuracyBlunderJob:
|
||||
|
||||
def main(args: Array[String]): Unit =
|
||||
val jdbcUrl = sys.env.getOrElse("NOWCHESS_JDBC_URL", "jdbc:postgresql://localhost:5432/nowchess")
|
||||
val dbUser = sys.env.getOrElse("NOWCHESS_DB_USER", "nowchess")
|
||||
val dbPass = sys.env.getOrElse("NOWCHESS_DB_PASS", "nowchess")
|
||||
val outputDir = if args.length > 0 then args(0) else "/tmp/nowchess-accuracy"
|
||||
|
||||
val spark = SparkSession
|
||||
.builder()
|
||||
.appName("NowChess Accuracy & Blunders")
|
||||
.getOrCreate()
|
||||
|
||||
run(spark, jdbcUrl, dbUser, dbPass, outputDir)
|
||||
spark.stop()
|
||||
|
||||
def run(spark: SparkSession, jdbcUrl: String, dbUser: String, dbPass: String, outputDir: String): Unit =
|
||||
val games = GameSource
|
||||
.loadExtended(spark, jdbcUrl, dbUser, dbPass)
|
||||
.select("pgn", "result", "white_elo", "black_elo")
|
||||
.filter(F.col("result").isNotNull.and(F.col("pgn").contains("[%eval")))
|
||||
.withColumn("game_id", F.monotonically_increasing_id())
|
||||
|
||||
// White-POV pawn evals in ply order; mate → ±10, normal evals clamped to ±10.
|
||||
val evalStrs = F.expr("""regexp_extract_all(pgn, '\\[%eval ([^\\]]+)\\]', 1)""")
|
||||
val evalCps = F.expr(
|
||||
"transform(eval_strs, x -> CASE " +
|
||||
"WHEN x LIKE '#-%' THEN -10.0 " +
|
||||
"WHEN x LIKE '#%' THEN 10.0 " +
|
||||
"ELSE greatest(-10.0, least(10.0, cast(x as double))) END)",
|
||||
)
|
||||
|
||||
val withEvals = games
|
||||
.withColumn("eval_strs", evalStrs)
|
||||
.withColumn("eval_cp", evalCps)
|
||||
.filter(F.size(F.col("eval_cp")) >= 2)
|
||||
|
||||
val plies = withEvals.select(
|
||||
F.col("game_id"),
|
||||
F.col("result"),
|
||||
F.col("white_elo"),
|
||||
F.col("black_elo"),
|
||||
F.posexplode(F.col("eval_cp")).as(Seq("ply", "eval_after")),
|
||||
)
|
||||
|
||||
val byGame = Window.partitionBy("game_id").orderBy("ply")
|
||||
val mover = F.when(F.col("ply") % 2 === 0, "white").otherwise("black")
|
||||
val evalBefore = F.coalesce(F.lag("eval_after", 1).over(byGame), F.lit(0.15))
|
||||
val cpl = F.greatest(
|
||||
F.lit(0.0),
|
||||
F.when(F.col("mover") === "white", evalBefore - F.col("eval_after"))
|
||||
.otherwise(F.col("eval_after") - evalBefore),
|
||||
) * 100
|
||||
|
||||
val moves = plies
|
||||
.withColumn("mover", mover)
|
||||
.withColumn("cpl", cpl)
|
||||
|
||||
val perSide = moves
|
||||
.groupBy("game_id", "mover", "result", "white_elo", "black_elo")
|
||||
.agg(
|
||||
F.round(F.avg("cpl"), 1).as("acpl"),
|
||||
F.sum(F.when(F.col("cpl") >= 200, 1).otherwise(0)).as("blunders"),
|
||||
F.sum(F.when(F.col("cpl") >= 100 && F.col("cpl") < 200, 1).otherwise(0)).as("mistakes"),
|
||||
F.sum(F.when(F.col("cpl") >= 50 && F.col("cpl") < 100, 1).otherwise(0)).as("inaccuracies"),
|
||||
)
|
||||
.withColumn(
|
||||
"self_elo",
|
||||
F.when(F.col("mover") === "white", F.col("white_elo")).otherwise(F.col("black_elo")),
|
||||
)
|
||||
.withColumn("won", F.when(F.col("mover") === F.col("result"), 1).otherwise(0))
|
||||
|
||||
writeAccuracyByRating(perSide, jdbcUrl, dbUser, dbPass, outputDir)
|
||||
writeBlunderOutcome(perSide, jdbcUrl, dbUser, dbPass, outputDir)
|
||||
|
||||
private def writeAccuracyByRating(
|
||||
perSide: org.apache.spark.sql.DataFrame,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
outputDir: String,
|
||||
): Unit =
|
||||
val elo = F.col("self_elo")
|
||||
val band = F
|
||||
.when(elo < 1200, "<1200")
|
||||
.when(elo < 1500, "1200–1499")
|
||||
.when(elo < 1800, "1500–1799")
|
||||
.when(elo < 2100, "1800–2099")
|
||||
.otherwise("2100+")
|
||||
val bandOrder = F
|
||||
.when(elo < 1200, 1)
|
||||
.when(elo < 1500, 2)
|
||||
.when(elo < 1800, 3)
|
||||
.when(elo < 2100, 4)
|
||||
.otherwise(5)
|
||||
|
||||
val stats = perSide
|
||||
.filter(elo.isNotNull)
|
||||
.withColumn("band", band)
|
||||
.withColumn("band_order", bandOrder)
|
||||
.groupBy("band", "band_order")
|
||||
.agg(
|
||||
F.count("*").as("player_games"),
|
||||
F.round(F.avg("acpl"), 1).as("avg_acpl"),
|
||||
F.round(F.avg("blunders"), 2).as("avg_blunders"),
|
||||
F.round(F.avg("mistakes"), 2).as("avg_mistakes"),
|
||||
F.round(F.avg("inaccuracies"), 2).as("avg_inaccuracies"),
|
||||
F.round(F.avg("won"), 3).as("win_rate"),
|
||||
)
|
||||
.orderBy(F.asc("band_order"))
|
||||
.drop("band_order")
|
||||
|
||||
write(stats, outputDir, "accuracy_by_rating", jdbcUrl, dbUser, dbPass, "analytics_accuracy_by_rating")
|
||||
|
||||
private def writeBlunderOutcome(
|
||||
perSide: org.apache.spark.sql.DataFrame,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
outputDir: String,
|
||||
): Unit =
|
||||
val b = F.col("blunders")
|
||||
val bucket = F.when(b === 0, "0").when(b === 1, "1").when(b === 2, "2").otherwise("3+")
|
||||
val order = F.when(b === 0, 0).when(b === 1, 1).when(b === 2, 2).otherwise(3)
|
||||
|
||||
val stats = perSide
|
||||
.withColumn("blunder_bucket", bucket)
|
||||
.withColumn("bucket_order", order)
|
||||
.groupBy("blunder_bucket", "bucket_order")
|
||||
.agg(
|
||||
F.count("*").as("player_games"),
|
||||
F.round(F.avg("won"), 3).as("win_rate"),
|
||||
F.round(F.avg("acpl"), 1).as("avg_acpl"),
|
||||
)
|
||||
.orderBy(F.asc("bucket_order"))
|
||||
.drop("bucket_order")
|
||||
|
||||
write(stats, outputDir, "blunder_outcome", jdbcUrl, dbUser, dbPass, "analytics_blunder_outcome")
|
||||
|
||||
private def write(
|
||||
df: org.apache.spark.sql.DataFrame,
|
||||
outputDir: String,
|
||||
name: String,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
table: String,
|
||||
): Unit =
|
||||
df.write.mode("overwrite").parquet(s"$outputDir/$name")
|
||||
df.write.mode("overwrite").option("header", "true").csv(s"$outputDir/${name}_csv")
|
||||
if !GameSource.isPgnMode then
|
||||
df.write
|
||||
.mode("overwrite")
|
||||
.format("jdbc")
|
||||
.option("url", jdbcUrl)
|
||||
.option("dbtable", table)
|
||||
.option("user", dbUser)
|
||||
.option("password", dbPass)
|
||||
.option("driver", "org.postgresql.Driver")
|
||||
.save()
|
||||
@@ -0,0 +1,199 @@
|
||||
package de.nowchess.analytics
|
||||
|
||||
import org.apache.spark.sql.SparkSession
|
||||
import org.apache.spark.sql.expressions.Window
|
||||
import org.apache.spark.sql.functions as F
|
||||
|
||||
/** Time-management & clock-pressure analysis mined from Lichess `[%clk ...]` move annotations.
|
||||
*
|
||||
* Lichess records each player's remaining clock after every move (`{ [%clk 0:02:31] }`). This job reconstructs
|
||||
* per-move thinking time and remaining-time from those stamps to answer questions the existing time-control summary
|
||||
* cannot: how long do players actually think, how often do they fall into time scrambles (<10 s left), how often do
|
||||
* they flag (lose on time), and does burning the clock correlate with winning?
|
||||
*
|
||||
* Pipeline (Spark SQL string/array funcs + window funcs — no UDFs):
|
||||
* 1. `regexp_extract_all` pulls every `h:mm:ss` clock in ply order, converted to seconds.
|
||||
* 2. `posexplode` → one row per ply; even plies are White's clock, odd plies Black's.
|
||||
* 3. A per-(game,side) window `lag` gives the same side's previous clock; the difference is that move's thinking time.
|
||||
* Remaining clock <10 s marks a time-scramble move.
|
||||
* 4. Roll up to (game, side): avg move time, scramble fraction, min clock, Elo, win flag, and whether the side lost on
|
||||
* time (`Termination "Time forfeit"`).
|
||||
*
|
||||
* Outputs (Parquet + CSV + JDBC):
|
||||
* - `clock_by_rating` — avg move time, scramble fraction, flag-loss rate and win-rate per Elo band.
|
||||
* - `scramble_outcome` — win-rate bucketed by how much of the game was played in time-scramble. Quantifies the cost of
|
||||
* time trouble.
|
||||
*
|
||||
* Requires a clock-annotated Lichess dump (`NOWCHESS_PGN_PATH`).
|
||||
*/
|
||||
object ClockPressureJob:
|
||||
|
||||
def main(args: Array[String]): Unit =
|
||||
val jdbcUrl = sys.env.getOrElse("NOWCHESS_JDBC_URL", "jdbc:postgresql://localhost:5432/nowchess")
|
||||
val dbUser = sys.env.getOrElse("NOWCHESS_DB_USER", "nowchess")
|
||||
val dbPass = sys.env.getOrElse("NOWCHESS_DB_PASS", "nowchess")
|
||||
val outputDir = if args.length > 0 then args(0) else "/tmp/nowchess-clock-pressure"
|
||||
|
||||
val spark = SparkSession
|
||||
.builder()
|
||||
.appName("NowChess Clock Pressure")
|
||||
.getOrCreate()
|
||||
|
||||
run(spark, jdbcUrl, dbUser, dbPass, outputDir)
|
||||
spark.stop()
|
||||
|
||||
def run(spark: SparkSession, jdbcUrl: String, dbUser: String, dbPass: String, outputDir: String): Unit =
|
||||
val games = GameSource
|
||||
.loadExtended(spark, jdbcUrl, dbUser, dbPass)
|
||||
.select("pgn", "result", "white_elo", "black_elo", "termination")
|
||||
.filter(F.col("result").isNotNull.and(F.col("pgn").contains("[%clk")))
|
||||
.withColumn("game_id", F.monotonically_increasing_id())
|
||||
|
||||
val clkStrs = F.expr("""regexp_extract_all(pgn, '\\[%clk ([^\\]]+)\\]', 1)""")
|
||||
// "h:mm:ss" → seconds.
|
||||
val clkSecs = F.expr(
|
||||
"transform(clk_strs, x -> " +
|
||||
"cast(split(x, ':')[0] as double) * 3600 + " +
|
||||
"cast(split(x, ':')[1] as double) * 60 + " +
|
||||
"cast(split(x, ':')[2] as double))",
|
||||
)
|
||||
|
||||
val withClk = games
|
||||
.withColumn("clk_strs", clkStrs)
|
||||
.withColumn("clk_sec", clkSecs)
|
||||
.filter(F.size(F.col("clk_sec")) >= 4)
|
||||
|
||||
val plies = withClk.select(
|
||||
F.col("game_id"),
|
||||
F.col("result"),
|
||||
F.col("white_elo"),
|
||||
F.col("black_elo"),
|
||||
F.col("termination"),
|
||||
F.posexplode(F.col("clk_sec")).as(Seq("ply", "clk_after")),
|
||||
)
|
||||
|
||||
val mover = F.when(F.col("ply") % 2 === 0, "white").otherwise("black")
|
||||
val bySide = Window.partitionBy("game_id", "mover").orderBy("ply")
|
||||
val moveTime = F.lag("clk_after", 1).over(bySide) - F.col("clk_after")
|
||||
|
||||
val moves = plies
|
||||
.withColumn("mover", mover)
|
||||
.withColumn("move_time", moveTime)
|
||||
|
||||
val perSide = moves
|
||||
.groupBy("game_id", "mover", "result", "white_elo", "black_elo", "termination")
|
||||
.agg(
|
||||
F.round(F.avg("move_time"), 1).as("avg_move_time"),
|
||||
F.count("*").as("moves"),
|
||||
F.round(F.min("clk_after"), 1).as("min_clk"),
|
||||
F.sum(F.when(F.col("clk_after") < 10, 1).otherwise(0)).as("scramble_moves"),
|
||||
)
|
||||
.withColumn("scramble_fraction", F.round(F.col("scramble_moves") / F.col("moves"), 3))
|
||||
.withColumn(
|
||||
"self_elo",
|
||||
F.when(F.col("mover") === "white", F.col("white_elo")).otherwise(F.col("black_elo")),
|
||||
)
|
||||
.withColumn("won", F.when(F.col("mover") === F.col("result"), 1).otherwise(0))
|
||||
.withColumn(
|
||||
"flag_loss",
|
||||
F.when(
|
||||
F.coalesce(F.col("termination"), F.lit("")).contains("Time forfeit") && F.col("won") === 0,
|
||||
1,
|
||||
).otherwise(0),
|
||||
)
|
||||
|
||||
writeClockByRating(perSide, jdbcUrl, dbUser, dbPass, outputDir)
|
||||
writeScrambleOutcome(perSide, jdbcUrl, dbUser, dbPass, outputDir)
|
||||
|
||||
private def writeClockByRating(
|
||||
perSide: org.apache.spark.sql.DataFrame,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
outputDir: String,
|
||||
): Unit =
|
||||
val elo = F.col("self_elo")
|
||||
val band = F
|
||||
.when(elo < 1200, "<1200")
|
||||
.when(elo < 1500, "1200–1499")
|
||||
.when(elo < 1800, "1500–1799")
|
||||
.when(elo < 2100, "1800–2099")
|
||||
.otherwise("2100+")
|
||||
val bandOrder = F
|
||||
.when(elo < 1200, 1)
|
||||
.when(elo < 1500, 2)
|
||||
.when(elo < 1800, 3)
|
||||
.when(elo < 2100, 4)
|
||||
.otherwise(5)
|
||||
|
||||
val stats = perSide
|
||||
.filter(elo.isNotNull)
|
||||
.withColumn("band", band)
|
||||
.withColumn("band_order", bandOrder)
|
||||
.groupBy("band", "band_order")
|
||||
.agg(
|
||||
F.count("*").as("player_games"),
|
||||
F.round(F.avg("avg_move_time"), 1).as("avg_move_time_s"),
|
||||
F.round(F.avg("scramble_fraction"), 3).as("avg_scramble_fraction"),
|
||||
F.round(F.avg("flag_loss"), 3).as("flag_loss_rate"),
|
||||
F.round(F.avg("won"), 3).as("win_rate"),
|
||||
)
|
||||
.orderBy(F.asc("band_order"))
|
||||
.drop("band_order")
|
||||
|
||||
write(stats, outputDir, "clock_by_rating", jdbcUrl, dbUser, dbPass, "analytics_clock_by_rating")
|
||||
|
||||
private def writeScrambleOutcome(
|
||||
perSide: org.apache.spark.sql.DataFrame,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
outputDir: String,
|
||||
): Unit =
|
||||
val sf = F.col("scramble_fraction")
|
||||
val bucket = F
|
||||
.when(sf === 0, "none")
|
||||
.when(sf < 0.05, "<5%")
|
||||
.when(sf < 0.20, "5–20%")
|
||||
.otherwise(">20%")
|
||||
val order = F
|
||||
.when(sf === 0, 0)
|
||||
.when(sf < 0.05, 1)
|
||||
.when(sf < 0.20, 2)
|
||||
.otherwise(3)
|
||||
|
||||
val stats = perSide
|
||||
.withColumn("scramble_bucket", bucket)
|
||||
.withColumn("bucket_order", order)
|
||||
.groupBy("scramble_bucket", "bucket_order")
|
||||
.agg(
|
||||
F.count("*").as("player_games"),
|
||||
F.round(F.avg("won"), 3).as("win_rate"),
|
||||
F.round(F.avg("flag_loss"), 3).as("flag_loss_rate"),
|
||||
)
|
||||
.orderBy(F.asc("bucket_order"))
|
||||
.drop("bucket_order")
|
||||
|
||||
write(stats, outputDir, "scramble_outcome", jdbcUrl, dbUser, dbPass, "analytics_scramble_outcome")
|
||||
|
||||
private def write(
|
||||
df: org.apache.spark.sql.DataFrame,
|
||||
outputDir: String,
|
||||
name: String,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
table: String,
|
||||
): Unit =
|
||||
df.write.mode("overwrite").parquet(s"$outputDir/$name")
|
||||
df.write.mode("overwrite").option("header", "true").csv(s"$outputDir/${name}_csv")
|
||||
if !GameSource.isPgnMode then
|
||||
df.write
|
||||
.mode("overwrite")
|
||||
.format("jdbc")
|
||||
.option("url", jdbcUrl)
|
||||
.option("dbtable", table)
|
||||
.option("user", dbUser)
|
||||
.option("password", dbPass)
|
||||
.option("driver", "org.postgresql.Driver")
|
||||
.save()
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.nowchess.analytics
|
||||
|
||||
import org.apache.spark.sql.SparkSession
|
||||
import org.apache.spark.sql.expressions.Window
|
||||
import org.apache.spark.sql.functions as F
|
||||
|
||||
/** Smurf / sandbagging anomaly detection via population z-scores.
|
||||
*
|
||||
* Smurfs (strong players on fresh accounts) and sandbaggers leave a statistical signature: a win-rate, an upset-rate
|
||||
* (beating higher-rated opponents) and a self-Elo climb that sit far above the population norm. This job builds those
|
||||
* three features per player, standardises each against the whole player base, and flags the players whose combined
|
||||
* deviation is extreme.
|
||||
*
|
||||
* Features per player (from each game's own/opponent Elo):
|
||||
* - win_rate — fraction of decisive results won
|
||||
* - upset_rate — wins vs higher-rated opponents / games vs higher-rated opponents
|
||||
* - elo_climb — max self-Elo − min self-Elo across their games (rapid rating gain)
|
||||
*
|
||||
* Standardisation uses a single unbounded window (`Window.partitionBy()`), i.e. mean/stddev over every qualifying
|
||||
* player, so z = (x − μ) / σ. The composite anomaly score sums the three z-scores. No UDFs — pure SQL aggregates +
|
||||
* window functions, so Catalyst plans the whole job.
|
||||
*
|
||||
* Outputs (Parquet + CSV + JDBC):
|
||||
* - `anomaly_scores` — every qualifying player with features, z-scores and composite, ranked most-anomalous first.
|
||||
* - `flagged_smurfs` — the suspicious subset (high composite, or the classic high-winrate / few-games / steep-climb
|
||||
* profile).
|
||||
*
|
||||
* Meaningful only when Elo is present (Lichess dump); requires `minGames` (arg 1, default 15) to avoid small-sample
|
||||
* noise.
|
||||
*/
|
||||
object SmurfAnomalyJob:
|
||||
|
||||
def main(args: Array[String]): Unit =
|
||||
val jdbcUrl = sys.env.getOrElse("NOWCHESS_JDBC_URL", "jdbc:postgresql://localhost:5432/nowchess")
|
||||
val dbUser = sys.env.getOrElse("NOWCHESS_DB_USER", "nowchess")
|
||||
val dbPass = sys.env.getOrElse("NOWCHESS_DB_PASS", "nowchess")
|
||||
val outputDir = if args.length > 0 then args(0) else "/tmp/nowchess-smurf-anomaly"
|
||||
val minGames = if args.length > 1 then args(1).toInt else 15
|
||||
|
||||
val spark = SparkSession
|
||||
.builder()
|
||||
.appName("NowChess Smurf Anomaly Detection")
|
||||
.getOrCreate()
|
||||
|
||||
run(spark, jdbcUrl, dbUser, dbPass, outputDir, minGames)
|
||||
spark.stop()
|
||||
|
||||
def run(
|
||||
spark: SparkSession,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
outputDir: String,
|
||||
minGames: Int,
|
||||
): Unit =
|
||||
val games = GameSource
|
||||
.loadExtended(spark, jdbcUrl, dbUser, dbPass)
|
||||
.select("white_id", "black_id", "result", "white_elo", "black_elo")
|
||||
.filter(F.col("result").isNotNull)
|
||||
|
||||
val asWhite = games.select(
|
||||
F.col("white_id").as("player_id"),
|
||||
F.col("white_elo").as("self_elo"),
|
||||
F.col("black_elo").as("opp_elo"),
|
||||
F.when(F.col("result") === "white", 1).otherwise(0).as("won"),
|
||||
)
|
||||
val asBlack = games.select(
|
||||
F.col("black_id").as("player_id"),
|
||||
F.col("black_elo").as("self_elo"),
|
||||
F.col("white_elo").as("opp_elo"),
|
||||
F.when(F.col("result") === "black", 1).otherwise(0).as("won"),
|
||||
)
|
||||
|
||||
val playerGames = asWhite
|
||||
.union(asBlack)
|
||||
.filter(F.col("self_elo").isNotNull.and(F.col("opp_elo").isNotNull))
|
||||
|
||||
val higher = F.col("opp_elo") > F.col("self_elo")
|
||||
|
||||
val features = playerGames
|
||||
.groupBy("player_id")
|
||||
.agg(
|
||||
F.count("*").as("total_games"),
|
||||
F.round(F.avg("won"), 3).as("win_rate"),
|
||||
F.round(F.avg("self_elo"), 0).as("avg_self_elo"),
|
||||
(F.max("self_elo") - F.min("self_elo")).as("elo_climb"),
|
||||
F.sum(F.when(higher, 1).otherwise(0)).as("vs_higher"),
|
||||
F.sum(F.when(higher && F.col("won") === 1, 1).otherwise(0)).as("upsets"),
|
||||
)
|
||||
.filter(F.col("total_games") >= minGames)
|
||||
.withColumn("upset_rate", F.round(F.col("upsets") / F.greatest(F.col("vs_higher"), F.lit(1)), 3))
|
||||
|
||||
val all = Window.partitionBy()
|
||||
def z(col: String): org.apache.spark.sql.Column =
|
||||
val mean = F.avg(col).over(all)
|
||||
val std = F.stddev(col).over(all)
|
||||
F.round((F.col(col) - mean) / F.when(std === 0 || std.isNull, F.lit(1.0)).otherwise(std), 2)
|
||||
|
||||
val scored = features
|
||||
.withColumn("z_win_rate", z("win_rate"))
|
||||
.withColumn("z_upset_rate", z("upset_rate"))
|
||||
.withColumn("z_elo_climb", z("elo_climb"))
|
||||
.withColumn(
|
||||
"anomaly_score",
|
||||
F.round(F.col("z_win_rate") + F.col("z_upset_rate") + F.col("z_elo_climb"), 2),
|
||||
)
|
||||
.withColumn(
|
||||
"flagged",
|
||||
(F.col("anomaly_score") >= 4.0)
|
||||
.or(F.col("win_rate") >= 0.8 && F.col("total_games") < 50 && F.col("elo_climb") >= 300),
|
||||
)
|
||||
|
||||
val ordered = scored
|
||||
.select(
|
||||
"player_id",
|
||||
"total_games",
|
||||
"win_rate",
|
||||
"avg_self_elo",
|
||||
"elo_climb",
|
||||
"upset_rate",
|
||||
"z_win_rate",
|
||||
"z_upset_rate",
|
||||
"z_elo_climb",
|
||||
"anomaly_score",
|
||||
"flagged",
|
||||
)
|
||||
.orderBy(F.desc("anomaly_score"))
|
||||
|
||||
write(ordered, outputDir, "anomaly_scores", jdbcUrl, dbUser, dbPass, "analytics_smurf_anomaly")
|
||||
|
||||
val flagged = ordered.filter(F.col("flagged") === true)
|
||||
write(flagged, outputDir, "flagged_smurfs", jdbcUrl, dbUser, dbPass, "analytics_flagged_smurfs")
|
||||
|
||||
private def write(
|
||||
df: org.apache.spark.sql.DataFrame,
|
||||
outputDir: String,
|
||||
name: String,
|
||||
jdbcUrl: String,
|
||||
dbUser: String,
|
||||
dbPass: String,
|
||||
table: String,
|
||||
): Unit =
|
||||
df.write.mode("overwrite").parquet(s"$outputDir/$name")
|
||||
df.write.mode("overwrite").option("header", "true").csv(s"$outputDir/${name}_csv")
|
||||
if !GameSource.isPgnMode then
|
||||
df.write
|
||||
.mode("overwrite")
|
||||
.format("jdbc")
|
||||
.option("url", jdbcUrl)
|
||||
.option("dbtable", table)
|
||||
.option("user", dbUser)
|
||||
.option("password", dbPass)
|
||||
.option("driver", "org.postgresql.Driver")
|
||||
.save()
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=7
|
||||
MINOR=8
|
||||
PATCH=0
|
||||
|
||||
@@ -623,3 +623,472 @@
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** derive tournament game color from game endpoint ([#79](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/79)) ([bfc4672](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfc46723e615bb9b65f7f9bba5f53877c4f079a7))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** activate opening book in expert bot (native-safe) ([260db25](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/260db25803ec55ce99e55782791eabdc190dfed4))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** derive tournament game color from game endpoint ([#79](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/79)) ([bfc4672](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfc46723e615bb9b65f7f9bba5f53877c4f079a7))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** activate opening book in expert bot (native-safe) ([260db25](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/260db25803ec55ce99e55782791eabdc190dfed4))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** derive tournament game color from game endpoint ([#79](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/79)) ([bfc4672](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfc46723e615bb9b65f7f9bba5f53877c4f079a7))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
* **official-bots:** use ThreadLocalRandom in PolyglotBook for native image ([1b30c3b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1b30c3be393d25712c8743d3d9057207f8bbb67c))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** activate opening book in expert bot (native-safe) ([260db25](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/260db25803ec55ce99e55782791eabdc190dfed4))
|
||||
* **official-bots:** add Google Colab notebook for NNUE training (NCS-111) ([#81](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/81)) ([fa10852](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fa10852bc98451d4068ec6fb9e7a486b5e53ef5c))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** implement king-relative (HalfKP) encoding in NNUE (NCS-109) ([#80](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/80)) ([44f376f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/44f376f03221f086b898741436e13c93fd314dd1))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* modified training pipeline ([9f9140c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9f9140cb585345cd244a1dfee1a06e51a5f7f7a8))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** derive tournament game color from game endpoint ([#79](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/79)) ([bfc4672](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfc46723e615bb9b65f7f9bba5f53877c4f079a7))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
* **official-bots:** use ThreadLocalRandom in PolyglotBook for native image ([1b30c3b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1b30c3be393d25712c8743d3d9057207f8bbb67c))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
## (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
|
||||
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
|
||||
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
|
||||
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
|
||||
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
|
||||
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
|
||||
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
|
||||
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
|
||||
* **ncs-110:** feed NNUE root-move scores into search move ordering ([#83](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/83)) ([e4fee85](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e4fee8513430093d46957970618935e99591519f))
|
||||
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
|
||||
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **official-bots:** activate opening book in expert bot (native-safe) ([260db25](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/260db25803ec55ce99e55782791eabdc190dfed4))
|
||||
* **official-bots:** add Google Colab notebook for NNUE training (NCS-111) ([#81](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/81)) ([fa10852](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fa10852bc98451d4068ec6fb9e7a486b5e53ef5c))
|
||||
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
|
||||
* **official-bots:** implement king-relative (HalfKP) encoding in NNUE (NCS-109) ([#80](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/80)) ([44f376f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/44f376f03221f086b898741436e13c93fd314dd1))
|
||||
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
|
||||
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
|
||||
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
|
||||
* modified training pipeline ([9f9140c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9f9140cb585345cd244a1dfee1a06e51a5f7f7a8))
|
||||
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
|
||||
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
|
||||
* **official-bots:** derive tournament game color from game endpoint ([#79](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/79)) ([bfc4672](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfc46723e615bb9b65f7f9bba5f53877c4f079a7))
|
||||
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
|
||||
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
|
||||
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
|
||||
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
|
||||
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
|
||||
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
|
||||
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
|
||||
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
|
||||
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
|
||||
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
|
||||
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
|
||||
* **official-bots:** use ThreadLocalRandom in PolyglotBook for native image ([1b30c3b](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1b30c3be393d25712c8743d3d9057207f8bbb67c))
|
||||
|
||||
### Reverts
|
||||
|
||||
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.10.0"
|
||||
},
|
||||
"colab": {
|
||||
"provenance": [],
|
||||
"gpuType": "T4"
|
||||
},
|
||||
"accelerator": "GPU"
|
||||
},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# NNUE Training Pipeline\n",
|
||||
"\n",
|
||||
"End-to-end notebook: data generation → Stockfish labeling → training → `.nbai` export.\n",
|
||||
"\n",
|
||||
"**Runtime:** GPU (T4 or better). Runtime → Change runtime type → T4 GPU.\n",
|
||||
"\n",
|
||||
"**Persistence:** Checkpoints and datasets are saved to Google Drive so training can resume after session timeout."
|
||||
],
|
||||
"id": "intro-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## ⚙️ 1 — Setup"
|
||||
],
|
||||
"id": "setup-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Mount Google Drive for checkpoint persistence\n",
|
||||
"from google.colab import drive\n",
|
||||
"drive.mount('/content/drive')"
|
||||
],
|
||||
"id": "mount-drive"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"\n",
|
||||
"# ── Configure these paths once ───────────────────────────────────────────────\n",
|
||||
"REPO_URL = 'https://git.janis-eccarius.de/NowChess/NowChessSystems.git'\n",
|
||||
"DRIVE_ROOT = '/content/drive/MyDrive/NowChess'\n",
|
||||
"REPO_DIR = f'{DRIVE_ROOT}/NowChessSystems'\n",
|
||||
"PYTHON_DIR = f'{REPO_DIR}/modules/official-bots/python'\n",
|
||||
"# ─────────────────────────────────────────────────────────────────────────────\n",
|
||||
"\n",
|
||||
"os.makedirs(DRIVE_ROOT, exist_ok=True)\n",
|
||||
"\n",
|
||||
"if not os.path.isdir(REPO_DIR):\n",
|
||||
" !git clone --depth=1 \"{REPO_URL}\" \"{REPO_DIR}\"\n",
|
||||
" print('Repo cloned to Drive.')\n",
|
||||
"else:\n",
|
||||
" !git -C \"{REPO_DIR}\" pull --ff-only\n",
|
||||
" print('Repo updated.')"
|
||||
],
|
||||
"id": "clone-repo"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Install Python dependencies\n",
|
||||
"!pip install -q chess tqdm rich zstandard\n",
|
||||
"\n",
|
||||
"# Stockfish for position labeling\n",
|
||||
"!apt-get install -q -y stockfish\n",
|
||||
"import shutil\n",
|
||||
"STOCKFISH_PATH = shutil.which('stockfish') or '/usr/games/stockfish'\n",
|
||||
"print(f'Stockfish: {STOCKFISH_PATH}')\n",
|
||||
"\n",
|
||||
"# Add pipeline source to path\n",
|
||||
"import sys\n",
|
||||
"sys.path.insert(0, f'{PYTHON_DIR}/src')\n",
|
||||
"sys.path.insert(0, PYTHON_DIR)\n",
|
||||
"print('Python path configured.')"
|
||||
],
|
||||
"id": "install-deps"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## 🗄️ 2 — Data\n",
|
||||
"\n",
|
||||
"Choose **one** of the two options below:\n",
|
||||
"- **Option A** — generate FEN positions with random play, then label them with Stockfish.\n",
|
||||
"- **Option B** — upload an existing `labeled.jsonl` from your machine or Drive."
|
||||
],
|
||||
"id": "data-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"# Paths (all on Drive so they survive session restarts)\n",
|
||||
"DATA_DIR = Path(DRIVE_ROOT) / 'training_data'\n",
|
||||
"DATA_DIR.mkdir(parents=True, exist_ok=True)\n",
|
||||
"POSITIONS_FILE = DATA_DIR / 'positions.txt' # raw FENs\n",
|
||||
"LABELED_FILE = DATA_DIR / 'labeled.jsonl' # FEN + eval pairs\n",
|
||||
"\n",
|
||||
"print(f'Data directory: {DATA_DIR}')"
|
||||
],
|
||||
"id": "data-paths"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Option A: Generate + label ────────────────────────────────────────────────\n",
|
||||
"# Adjust NUM_POSITIONS to taste. 50 000 trains in ~10 min on T4;\n",
|
||||
"# 200 000+ gives better generalisation.\n",
|
||||
"NUM_POSITIONS = 50_000\n",
|
||||
"STOCKFISH_DEPTH = 12\n",
|
||||
"LABEL_WORKERS = 4 # parallel Stockfish processes\n",
|
||||
"MIN_MOVE = 5 # skip opening book moves\n",
|
||||
"MAX_MOVE = 60\n",
|
||||
"\n",
|
||||
"from generate import play_random_game_and_collect_positions\n",
|
||||
"from label import label_positions_with_stockfish\n",
|
||||
"\n",
|
||||
"print(f'Generating {NUM_POSITIONS:,} positions...')\n",
|
||||
"count = play_random_game_and_collect_positions(\n",
|
||||
" str(POSITIONS_FILE),\n",
|
||||
" total_positions=NUM_POSITIONS,\n",
|
||||
" samples_per_game=1,\n",
|
||||
" min_move=MIN_MOVE,\n",
|
||||
" max_move=MAX_MOVE,\n",
|
||||
" num_workers=4,\n",
|
||||
")\n",
|
||||
"print(f'{count:,} positions written to {POSITIONS_FILE}')\n",
|
||||
"\n",
|
||||
"print('Labeling with Stockfish (this is the slow step)...')\n",
|
||||
"ok = label_positions_with_stockfish(\n",
|
||||
" str(POSITIONS_FILE),\n",
|
||||
" str(LABELED_FILE),\n",
|
||||
" STOCKFISH_PATH,\n",
|
||||
" depth=STOCKFISH_DEPTH,\n",
|
||||
" num_workers=LABEL_WORKERS,\n",
|
||||
")\n",
|
||||
"if ok:\n",
|
||||
" print(f'Labeled dataset saved: {LABELED_FILE}')\n",
|
||||
"else:\n",
|
||||
" print('ERROR: labeling failed')"
|
||||
],
|
||||
"id": "option-a-generate"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Option B: Upload existing labeled.jsonl ───────────────────────────────────\n",
|
||||
"# Run this cell instead of Option A if you already have a labeled dataset.\n",
|
||||
"#\n",
|
||||
"# To upload from local machine:\n",
|
||||
"# from google.colab import files\n",
|
||||
"# uploaded = files.upload() # pick your labeled.jsonl\n",
|
||||
"# import shutil, os\n",
|
||||
"# shutil.move(next(iter(uploaded)), str(LABELED_FILE))\n",
|
||||
"#\n",
|
||||
"# Or copy from Drive:\n",
|
||||
"# import shutil\n",
|
||||
"# shutil.copy('/content/drive/MyDrive/path/to/labeled.jsonl', str(LABELED_FILE))\n",
|
||||
"\n",
|
||||
"import os\n",
|
||||
"if LABELED_FILE.exists():\n",
|
||||
" lines = sum(1 for _ in open(LABELED_FILE))\n",
|
||||
" print(f'Ready: {lines:,} labeled positions at {LABELED_FILE}')\n",
|
||||
"else:\n",
|
||||
" print('No labeled.jsonl found — run Option A first or upload one.')"
|
||||
],
|
||||
"id": "option-b-upload"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## 🏋️ 3 — Train\n",
|
||||
"\n",
|
||||
"Standard training runs a fixed number of epochs. \n",
|
||||
"**Burst mode** is better for Colab: it repeatedly restarts from the best checkpoint within a time budget, surviving session disconnects gracefully."
|
||||
],
|
||||
"id": "train-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from train import train_nnue, burst_train, DEFAULT_HIDDEN_SIZES\n",
|
||||
"\n",
|
||||
"WEIGHTS_DIR = Path(DRIVE_ROOT) / 'weights'\n",
|
||||
"WEIGHTS_DIR.mkdir(parents=True, exist_ok=True)\n",
|
||||
"OUTPUT_FILE = str(WEIGHTS_DIR / 'nnue_weights.pt')\n",
|
||||
"\n",
|
||||
"# ── Training hyperparameters ──────────────────────────────────────────────────\n",
|
||||
"HIDDEN_SIZES = DEFAULT_HIDDEN_SIZES # [1536, 1024, 512, 256]\n",
|
||||
"BATCH_SIZE = 16384\n",
|
||||
"EPOCHS = 100\n",
|
||||
"EARLY_STOPPING = 10 # None to disable\n",
|
||||
"SUBSAMPLE_RATIO = 1.0\n",
|
||||
"\n",
|
||||
"# Resume from latest checkpoint if one exists\n",
|
||||
"checkpoints = sorted(WEIGHTS_DIR.glob('nnue_weights_v*.pt'))\n",
|
||||
"CHECKPOINT = str(checkpoints[-1]) if checkpoints else None\n",
|
||||
"if CHECKPOINT:\n",
|
||||
" print(f'Resuming from checkpoint: {CHECKPOINT}')\n",
|
||||
"else:\n",
|
||||
" print('Starting training from scratch.')"
|
||||
],
|
||||
"id": "train-config"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Standard training ─────────────────────────────────────────────────────────\n",
|
||||
"# Use this when you have a reliable long-running session.\n",
|
||||
"\n",
|
||||
"train_nnue(\n",
|
||||
" data_file=str(LABELED_FILE),\n",
|
||||
" output_file=OUTPUT_FILE,\n",
|
||||
" epochs=EPOCHS,\n",
|
||||
" batch_size=BATCH_SIZE,\n",
|
||||
" checkpoint=CHECKPOINT,\n",
|
||||
" use_versioning=True,\n",
|
||||
" early_stopping_patience=EARLY_STOPPING,\n",
|
||||
" subsample_ratio=SUBSAMPLE_RATIO,\n",
|
||||
" hidden_sizes=HIDDEN_SIZES,\n",
|
||||
")"
|
||||
],
|
||||
"id": "standard-train"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# ── Burst training (recommended for Colab free tier) ─────────────────────────\n",
|
||||
"# Restarts from the global best each time early stopping fires.\n",
|
||||
"# Set BURST_MINUTES to slightly less than the Colab session limit (~70 min).\n",
|
||||
"\n",
|
||||
"BURST_MINUTES = 70\n",
|
||||
"EPOCHS_PER_SEASON = 30\n",
|
||||
"BURST_PATIENCE = 8\n",
|
||||
"\n",
|
||||
"burst_train(\n",
|
||||
" data_file=str(LABELED_FILE),\n",
|
||||
" output_file=OUTPUT_FILE,\n",
|
||||
" duration_minutes=BURST_MINUTES,\n",
|
||||
" epochs_per_season=EPOCHS_PER_SEASON,\n",
|
||||
" early_stopping_patience=BURST_PATIENCE,\n",
|
||||
" batch_size=BATCH_SIZE,\n",
|
||||
" initial_checkpoint=CHECKPOINT,\n",
|
||||
" use_versioning=True,\n",
|
||||
" subsample_ratio=SUBSAMPLE_RATIO,\n",
|
||||
" hidden_sizes=HIDDEN_SIZES,\n",
|
||||
")"
|
||||
],
|
||||
"id": "burst-train"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## 📦 4 — Export\n",
|
||||
"\n",
|
||||
"Convert the best `.pt` checkpoint to the `.nbai` binary format read by `NbaiLoader` in Scala."
|
||||
],
|
||||
"id": "export-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from export import export_to_nbai\n",
|
||||
"\n",
|
||||
"NBAI_FILE = Path(DRIVE_ROOT) / 'nnue_weights.nbai'\n",
|
||||
"\n",
|
||||
"# Pick the latest versioned checkpoint\n",
|
||||
"checkpoints = sorted(WEIGHTS_DIR.glob('nnue_weights_v*.pt'))\n",
|
||||
"if not checkpoints:\n",
|
||||
" raise FileNotFoundError('No checkpoints found in ' + str(WEIGHTS_DIR))\n",
|
||||
"\n",
|
||||
"latest = checkpoints[-1]\n",
|
||||
"print(f'Exporting {latest.name} → {NBAI_FILE.name}')\n",
|
||||
"\n",
|
||||
"export_to_nbai(\n",
|
||||
" weights_file=str(latest),\n",
|
||||
" output_file=str(NBAI_FILE),\n",
|
||||
" trained_by='colab',\n",
|
||||
")\n",
|
||||
"print('Export complete.')"
|
||||
],
|
||||
"id": "export-cell"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"## ⬇️ 5 — Download\n",
|
||||
"\n",
|
||||
"Download the `.nbai` weights file and the latest `.pt` checkpoint to your local machine.\n",
|
||||
"\n",
|
||||
"Place `nnue_weights.nbai` in `modules/official-bots/src/main/resources/` and rebuild the native image."
|
||||
],
|
||||
"id": "download-md"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"if NBAI_FILE.exists():\n",
|
||||
" files.download(str(NBAI_FILE))\n",
|
||||
" print(f'Downloading {NBAI_FILE.name}')\n",
|
||||
"else:\n",
|
||||
" print('No .nbai file found — run the Export cell first.')\n",
|
||||
"\n",
|
||||
"checkpoints = sorted(WEIGHTS_DIR.glob('nnue_weights_v*.pt'))\n",
|
||||
"if checkpoints:\n",
|
||||
" latest = checkpoints[-1]\n",
|
||||
" files.download(str(latest))\n",
|
||||
" print(f'Downloading checkpoint {latest.name}')\n",
|
||||
"else:\n",
|
||||
" print('No .pt checkpoint found.')"
|
||||
],
|
||||
"id": "download-cell"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,11 @@ class NNUEDataset(Dataset):
|
||||
eval_val = self.evals[idx]
|
||||
features = fen_to_features(fen)
|
||||
|
||||
# Board is flipped for Black-to-move in fen_to_features; negate eval
|
||||
# so the label still means "good for the side shown as White after flip"
|
||||
if ' b ' in fen:
|
||||
eval_val = -eval_val
|
||||
|
||||
# Use evaluation as-is if normalized, otherwise apply sigmoid scaling
|
||||
if self.is_normalized:
|
||||
target = torch.tensor(eval_val, dtype=torch.float32)
|
||||
@@ -61,38 +66,59 @@ class NNUEDataset(Dataset):
|
||||
|
||||
return features, target
|
||||
|
||||
# King-relative (HalfKP) encoding: two perspectives, one per side's king.
|
||||
# Each piece is encoded as: kingSq * 768 + pieceIdx * 64 + sq
|
||||
# White perspective uses white king square; black perspective uses black king square.
|
||||
# Total input dimension = 2 × 64 × 12 × 64 = 98304.
|
||||
_HALF_SIZE = 64 * 12 * 64 # 49152 features per perspective
|
||||
INPUT_SIZE = _HALF_SIZE * 2 # 98304
|
||||
|
||||
_PIECE_TO_IDX = {
|
||||
'p': 0, 'n': 1, 'b': 2, 'r': 3, 'q': 4, 'k': 5,
|
||||
'P': 6, 'N': 7, 'B': 8, 'R': 9, 'Q': 10, 'K': 11,
|
||||
}
|
||||
|
||||
|
||||
def fen_to_features(fen):
|
||||
"""Convert FEN to 768-dimensional binary feature vector."""
|
||||
# Piece type to index: pawn=0, knight=1, bishop=2, rook=3, queen=4, king=5
|
||||
piece_to_idx = {'p': 0, 'n': 1, 'b': 2, 'r': 3, 'q': 4, 'k': 5,
|
||||
'P': 6, 'N': 7, 'B': 8, 'R': 9, 'Q': 10, 'K': 11}
|
||||
|
||||
features = torch.zeros(768, dtype=torch.float32)
|
||||
"""Convert FEN to 98304-dim king-relative (HalfKP) feature vector.
|
||||
|
||||
For Black-to-move positions the board is mirrored (ranks flipped, colours
|
||||
swapped) so the network always sees the position from the side-to-move's
|
||||
perspective. The caller is responsible for negating the eval label to match.
|
||||
"""
|
||||
features = torch.zeros(INPUT_SIZE, dtype=torch.float32)
|
||||
try:
|
||||
board = chess.Board(fen)
|
||||
|
||||
# 12 piece types × 64 squares = 768
|
||||
for square in chess.SQUARES:
|
||||
piece = board.piece_at(square)
|
||||
if piece is not None:
|
||||
piece_char = piece.symbol()
|
||||
if piece_char in piece_to_idx:
|
||||
piece_idx = piece_to_idx[piece_char]
|
||||
feature_idx = piece_idx * 64 + square
|
||||
features[feature_idx] = 1.0
|
||||
except:
|
||||
# Perspective flip: present all positions as if White is to move
|
||||
if board.turn == chess.BLACK:
|
||||
board = board.mirror()
|
||||
wk = board.king(chess.WHITE)
|
||||
bk = board.king(chess.BLACK)
|
||||
if wk is None or bk is None:
|
||||
return features
|
||||
for sq in chess.SQUARES:
|
||||
piece = board.piece_at(sq)
|
||||
if piece is None:
|
||||
continue
|
||||
pidx = _PIECE_TO_IDX[piece.symbol()]
|
||||
# White-king perspective (indices 0 .. _HALF_SIZE-1)
|
||||
features[wk * 768 + pidx * 64 + sq] = 1.0
|
||||
# Black-king perspective (indices _HALF_SIZE .. INPUT_SIZE-1)
|
||||
features[_HALF_SIZE + bk * 768 + pidx * 64 + sq] = 1.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return features
|
||||
|
||||
DEFAULT_HIDDEN_SIZES = [1536, 1024, 512, 256]
|
||||
# Smaller hidden layers are appropriate: the L1 input is very sparse (~64 active
|
||||
# features out of 98304) so the L1 itself is cheap to update incrementally; the
|
||||
# larger capacity comes from the wider perspective encoding, not deeper layers.
|
||||
DEFAULT_HIDDEN_SIZES = [512, 256, 128]
|
||||
|
||||
|
||||
class NNUE(nn.Module):
|
||||
"""NNUE neural network with configurable hidden layers.
|
||||
|
||||
Architecture: 768 → hidden_sizes[0] → ... → hidden_sizes[-1] → 1
|
||||
Architecture: INPUT_SIZE → hidden_sizes[0] → ... → hidden_sizes[-1] → 1
|
||||
Layer attributes follow the naming l1, l2, ..., lN so export.py can
|
||||
infer the architecture directly from the state_dict.
|
||||
"""
|
||||
@@ -102,7 +128,7 @@ class NNUE(nn.Module):
|
||||
if hidden_sizes is None:
|
||||
hidden_sizes = DEFAULT_HIDDEN_SIZES
|
||||
self.hidden_sizes = list(hidden_sizes)
|
||||
sizes = [768] + self.hidden_sizes + [1]
|
||||
sizes = [INPUT_SIZE] + self.hidden_sizes + [1]
|
||||
num_hidden = len(self.hidden_sizes)
|
||||
|
||||
for i in range(num_hidden):
|
||||
|
||||
Binary file not shown.
@@ -1,14 +1,20 @@
|
||||
package de.nowchess.bot
|
||||
|
||||
import de.nowchess.bot.bots.ClassicalBot
|
||||
import de.nowchess.bot.bots.{ClassicalBot, HybridBot}
|
||||
import de.nowchess.bot.util.PolyglotBook
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import org.jboss.logging.Logger
|
||||
|
||||
object BotController:
|
||||
private val log = Logger.getLogger(classOf[BotController])
|
||||
|
||||
private val openingBook = PolyglotBook.fromResource("/opening_book.bin")
|
||||
|
||||
private val bots: Map[String, Bot] = Map(
|
||||
"easy" -> ClassicalBot(BotDifficulty.Easy),
|
||||
"medium" -> ClassicalBot(BotDifficulty.Medium),
|
||||
"hard" -> ClassicalBot(BotDifficulty.Hard),
|
||||
"expert" -> ClassicalBot(BotDifficulty.Expert),
|
||||
"expert" -> HybridBot(BotDifficulty.Expert, vetoReporter = log.debug(_), book = Some(openingBook)),
|
||||
)
|
||||
|
||||
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
|
||||
|
||||
@@ -24,16 +24,24 @@ object HybridBot:
|
||||
val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
|
||||
context =>
|
||||
val blockedMoves = BotMoveRepetition.blockedMoves(context)
|
||||
|
||||
def nnueScore(move: Move): Int = nnueEvaluation.evaluate(rules.applyMove(context)(move))
|
||||
def classicalScore(move: Move): Int = classicalEvaluation.evaluate(rules.applyMove(context)(move))
|
||||
|
||||
def refine(move: Move): Move =
|
||||
val moveNnue = nnueScore(move)
|
||||
if (classicalScore(move) - moveNnue).abs <= Config.VETO_THRESHOLD then move
|
||||
else
|
||||
search
|
||||
.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves + move)
|
||||
.filterNot(blockedMoves.contains)
|
||||
.filter(alt => nnueScore(alt) < moveNnue)
|
||||
.map { alt =>
|
||||
vetoReporter(f"[Veto] ${move.from}->${move.to} replaced by ${alt.from}->${alt.to} — NNUE prefers it")
|
||||
alt
|
||||
}
|
||||
.getOrElse(move)
|
||||
|
||||
book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse {
|
||||
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
|
||||
val next = rules.applyMove(context)(move)
|
||||
val staticNnue = nnueEvaluation.evaluate(next)
|
||||
val classical = classicalEvaluation.evaluate(next)
|
||||
val diff = (classical - staticNnue).abs
|
||||
if diff > Config.VETO_THRESHOLD then
|
||||
vetoReporter(
|
||||
f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
|
||||
)
|
||||
move
|
||||
}
|
||||
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map(refine)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ object NNUEBot:
|
||||
else
|
||||
val scored = batchEvaluateRoot(rules, context, moves)
|
||||
val bestMove = scored.maxBy(_._2)._1
|
||||
search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove))
|
||||
search.bestMoveWithTime(context, allocateTime(scored), blockedMoves, scored.toMap).orElse(Some(bestMove))
|
||||
}
|
||||
|
||||
private def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] =
|
||||
|
||||
@@ -23,9 +23,9 @@ object EvaluationNNUE extends Evaluation:
|
||||
nnue.copyAccumulator(parentPly, childPly)
|
||||
|
||||
override def pushAccumulator(childPly: Int, move: Move, parent: GameContext, child: GameContext): Unit =
|
||||
// Use incremental updates, but recompute from scratch every 10 plies to prevent accumulation errors
|
||||
// Recompute every 10 plies to prevent floating-point drift; king moves always recompute internally
|
||||
if childPly % 10 == 0 then nnue.recomputeAccumulator(childPly, child.board)
|
||||
else nnue.pushAccumulator(childPly, move, parent.board)
|
||||
else nnue.pushAccumulator(childPly, move, parent.board, child.board)
|
||||
|
||||
override def evaluateAccumulator(ply: Int, context: GameContext, hash: Long): Int =
|
||||
nnue.evaluateAtPlyWithValidation(ply, context.turn, hash, context.board)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package de.nowchess.bot.bots.nnue
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Square}
|
||||
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
|
||||
class NNUE(model: NbaiModel):
|
||||
|
||||
private val featureSize = model.layers(0).inputSize
|
||||
private val HALF_SIZE = 49152 // 64 king-squares × 12 piece-types × 64 piece-squares
|
||||
private val featureSize = model.layers(0).inputSize // 98304 (= HALF_SIZE * 2) for king-relative
|
||||
private val accSize = model.layers(0).outputSize
|
||||
private val validateAccum = sys.env.contains("NNUE_VALIDATE") // Enable with NNUE_VALIDATE=1
|
||||
private val validateAccum = sys.env.contains("NNUE_VALIDATE")
|
||||
|
||||
// Column-major L1 weights for cache-friendly sparse & incremental updates.
|
||||
// l1WeightsT(featureIdx * accSize + outputIdx) = l1Weights(outputIdx * featureSize + featureIdx)
|
||||
// Column-major L1 weights: l1WeightsT(featureIdx * accSize + outputIdx)
|
||||
private val l1WeightsT: Array[Float] =
|
||||
val w = model.weights(0).weights
|
||||
val t = new Array[Float](featureSize * accSize)
|
||||
@@ -23,7 +23,6 @@ class NNUE(model: NbaiModel):
|
||||
private val MAX_PLY = 128
|
||||
private val l1Stack: Array[Array[Float]] = Array.fill(MAX_PLY + 1)(new Array[Float](accSize))
|
||||
|
||||
// Shared evaluation buffers: index i holds the output of layers(i) (all except the scalar output layer).
|
||||
private val evalBuffers: Array[Array[Float]] = model.layers.init.map(l => new Array[Float](l.outputSize))
|
||||
|
||||
// ── Eval cache ───────────────────────────────────────────────────────────
|
||||
@@ -36,9 +35,29 @@ class NNUE(model: NbaiModel):
|
||||
|
||||
private def squareNum(sq: Square): Int = sq.rank.ordinal * 8 + sq.file.ordinal
|
||||
|
||||
private def featureIndex(piece: Piece, sqNum: Int): Int =
|
||||
val colorOffset = if piece.color == Color.White then 6 else 0
|
||||
(colorOffset + piece.pieceType.ordinal) * 64 + sqNum
|
||||
// Mirror square vertically (rank 0 ↔ rank 7) for the perspective flip
|
||||
private def flipSqNum(sqNum: Int): Int = (7 - sqNum / 8) * 8 + sqNum % 8
|
||||
|
||||
private def pieceIdx(piece: Piece): Int =
|
||||
if piece.color == Color.White then 6 + piece.pieceType.ordinal else piece.pieceType.ordinal
|
||||
|
||||
// White-king perspective: index in [0, HALF_SIZE)
|
||||
private def featureIdxWhite(piece: Piece, sqNum: Int, wkSq: Int): Int =
|
||||
wkSq * 768 + pieceIdx(piece) * 64 + sqNum
|
||||
|
||||
// Black-king perspective: index in [HALF_SIZE, featureSize)
|
||||
private def featureIdxBlack(piece: Piece, sqNum: Int, bkSq: Int): Int =
|
||||
HALF_SIZE + bkSq * 768 + pieceIdx(piece) * 64 + sqNum
|
||||
|
||||
private def wkSqOf(board: Board): Int =
|
||||
board.pieces
|
||||
.collectFirst { case (sq, p) if p.pieceType == PieceType.King && p.color == Color.White => squareNum(sq) }
|
||||
.getOrElse(0)
|
||||
|
||||
private def bkSqOf(board: Board): Int =
|
||||
board.pieces
|
||||
.collectFirst { case (sq, p) if p.pieceType == PieceType.King && p.color == Color.Black => squareNum(sq) }
|
||||
.getOrElse(0)
|
||||
|
||||
private def addColumn(l1Pre: Array[Float], featureIdx: Int): Unit =
|
||||
val offset = featureIdx * accSize
|
||||
@@ -48,92 +67,96 @@ class NNUE(model: NbaiModel):
|
||||
val offset = featureIdx * accSize
|
||||
for i <- 0 until accSize do l1Pre(i) -= l1WeightsT(offset + i)
|
||||
|
||||
private def addPiece(l1: Array[Float], piece: Piece, sqNum: Int, wkSq: Int, bkSq: Int): Unit =
|
||||
addColumn(l1, featureIdxWhite(piece, sqNum, wkSq))
|
||||
addColumn(l1, featureIdxBlack(piece, sqNum, bkSq))
|
||||
|
||||
private def removePiece(l1: Array[Float], piece: Piece, sqNum: Int, wkSq: Int, bkSq: Int): Unit =
|
||||
subtractColumn(l1, featureIdxWhite(piece, sqNum, wkSq))
|
||||
subtractColumn(l1, featureIdxBlack(piece, sqNum, bkSq))
|
||||
|
||||
// ── Accumulator init ─────────────────────────────────────────────────────
|
||||
|
||||
def initAccumulator(board: Board): Unit =
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
System.arraycopy(model.weights(0).bias, 0, l1Stack(0), 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addColumn(l1Stack(0), featureIndex(piece, squareNum(sq)))
|
||||
for (sq, piece) <- board.pieces do addPiece(l1Stack(0), piece, squareNum(sq), wkSq, bkSq)
|
||||
|
||||
// ── Accumulator push (incremental updates) ───────────────────────────────
|
||||
|
||||
def pushAccumulator(childPly: Int, move: Move, board: Board): Unit =
|
||||
def pushAccumulator(childPly: Int, move: Move, parentBoard: Board, childBoard: Board): Unit =
|
||||
System.arraycopy(l1Stack(childPly - 1), 0, l1Stack(childPly), 0, accSize)
|
||||
val l1 = l1Stack(childPly)
|
||||
move.moveType match
|
||||
case MoveType.Normal(_) => applyNormalDelta(l1, move, board)
|
||||
case MoveType.EnPassant => applyEnPassantDelta(l1, move, board)
|
||||
case MoveType.CastleKingside | MoveType.CastleQueenside => applyCastleDelta(l1, move, board)
|
||||
case MoveType.Promotion(p) => applyPromotionDelta(l1, move, p, board)
|
||||
if isKingMove(move, parentBoard) then recomputeAccumulatorInto(l1Stack(childPly), childBoard)
|
||||
else applyNonKingDelta(l1Stack(childPly), move, parentBoard)
|
||||
|
||||
private def isKingMove(move: Move, board: Board): Boolean =
|
||||
move.moveType == MoveType.CastleKingside ||
|
||||
move.moveType == MoveType.CastleQueenside ||
|
||||
board.pieceAt(move.from).exists(_.pieceType == PieceType.King)
|
||||
|
||||
def copyAccumulator(parentPly: Int, childPly: Int): Unit =
|
||||
System.arraycopy(l1Stack(parentPly), 0, l1Stack(childPly), 0, accSize)
|
||||
|
||||
def recomputeAccumulator(ply: Int, board: Board): Unit =
|
||||
System.arraycopy(model.weights(0).bias, 0, l1Stack(ply), 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addColumn(l1Stack(ply), featureIndex(piece, squareNum(sq)))
|
||||
recomputeAccumulatorInto(l1Stack(ply), board)
|
||||
|
||||
private def recomputeAccumulatorInto(l1: Array[Float], board: Board): Unit =
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
System.arraycopy(model.weights(0).bias, 0, l1, 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addPiece(l1, piece, squareNum(sq), wkSq, bkSq)
|
||||
|
||||
def validateAccumulator(ply: Int, board: Board): Boolean =
|
||||
// Compute what L1 should be from scratch
|
||||
val expectedL1 = new Array[Float](accSize)
|
||||
System.arraycopy(model.weights(0).bias, 0, expectedL1, 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addColumn(expectedL1, featureIndex(piece, squareNum(sq)))
|
||||
|
||||
// Compare with actual L1
|
||||
val expected = new Array[Float](accSize)
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
System.arraycopy(model.weights(0).bias, 0, expected, 0, accSize)
|
||||
for (sq, piece) <- board.pieces do addPiece(expected, piece, squareNum(sq), wkSq, bkSq)
|
||||
val actual = l1Stack(ply)
|
||||
val maxError =
|
||||
(0 until accSize).foldLeft(0f) { (currentMax, i) =>
|
||||
val error = math.abs(actual(i) - expectedL1(i))
|
||||
math.max(currentMax, error)
|
||||
}
|
||||
(0 until accSize).forall(i => math.abs(actual(i) - expected(i)) < 0.001f)
|
||||
|
||||
maxError < 0.001f // Allow small floating-point errors
|
||||
// ── Non-king incremental deltas ──────────────────────────────────────────
|
||||
|
||||
private def applyNormalDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
// Extract source and destination square indices early
|
||||
val fromNum = squareNum(move.from)
|
||||
val toNum = squareNum(move.to)
|
||||
private def applyNonKingDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
val wkSq = wkSqOf(board)
|
||||
val bkSq = bkSqOf(board)
|
||||
move.moveType match
|
||||
case MoveType.Normal(_) => applyNormalDelta(l1, move, board, wkSq, bkSq)
|
||||
case MoveType.EnPassant => applyEnPassantDelta(l1, move, board, wkSq, bkSq)
|
||||
case MoveType.Promotion(p) => applyPromotionDelta(l1, move, p, board, wkSq, bkSq)
|
||||
case _ => () // king moves handled before this point
|
||||
|
||||
// Get the moving piece
|
||||
private def applyNormalDelta(l1: Array[Float], move: Move, board: Board, wkSq: Int, bkSq: Int): Unit =
|
||||
board.pieceAt(move.from).foreach { mover =>
|
||||
subtractColumn(l1, featureIndex(mover, fromNum))
|
||||
|
||||
// If there's a capture, subtract the captured piece
|
||||
board.pieceAt(move.to).foreach { cap =>
|
||||
subtractColumn(l1, featureIndex(cap, toNum))
|
||||
}
|
||||
|
||||
// Add the piece to its new location
|
||||
addColumn(l1, featureIndex(mover, toNum))
|
||||
val fromNum = squareNum(move.from)
|
||||
val toNum = squareNum(move.to)
|
||||
removePiece(l1, mover, fromNum, wkSq, bkSq)
|
||||
board.pieceAt(move.to).foreach(cap => removePiece(l1, cap, toNum, wkSq, bkSq))
|
||||
addPiece(l1, mover, toNum, wkSq, bkSq)
|
||||
}
|
||||
|
||||
private def applyEnPassantDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
private def applyEnPassantDelta(l1: Array[Float], move: Move, board: Board, wkSq: Int, bkSq: Int): Unit =
|
||||
board.pieceAt(move.from).foreach { pawn =>
|
||||
val capturedSq = Square(move.to.file, move.from.rank)
|
||||
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
|
||||
board.pieceAt(capturedSq).foreach(cap => subtractColumn(l1, featureIndex(cap, squareNum(capturedSq))))
|
||||
addColumn(l1, featureIndex(pawn, squareNum(move.to)))
|
||||
removePiece(l1, pawn, squareNum(move.from), wkSq, bkSq)
|
||||
board.pieceAt(capturedSq).foreach(cap => removePiece(l1, cap, squareNum(capturedSq), wkSq, bkSq))
|
||||
addPiece(l1, pawn, squareNum(move.to), wkSq, bkSq)
|
||||
}
|
||||
|
||||
private def applyCastleDelta(l1: Array[Float], move: Move, board: Board): Unit =
|
||||
board.pieceAt(move.from).foreach { king =>
|
||||
val rank = move.from.rank
|
||||
val kingside = move.moveType == MoveType.CastleKingside
|
||||
val (rookFrom, rookTo) =
|
||||
if kingside then (Square(File.H, rank), Square(File.F, rank))
|
||||
else (Square(File.A, rank), Square(File.D, rank))
|
||||
val rook = Piece(king.color, PieceType.Rook)
|
||||
subtractColumn(l1, featureIndex(king, squareNum(move.from)))
|
||||
addColumn(l1, featureIndex(king, squareNum(move.to)))
|
||||
subtractColumn(l1, featureIndex(rook, squareNum(rookFrom)))
|
||||
addColumn(l1, featureIndex(rook, squareNum(rookTo)))
|
||||
}
|
||||
|
||||
private def applyPromotionDelta(l1: Array[Float], move: Move, promo: PromotionPiece, board: Board): Unit =
|
||||
private def applyPromotionDelta(
|
||||
l1: Array[Float],
|
||||
move: Move,
|
||||
promo: PromotionPiece,
|
||||
board: Board,
|
||||
wkSq: Int,
|
||||
bkSq: Int,
|
||||
): Unit =
|
||||
board.pieceAt(move.from).foreach { pawn =>
|
||||
val toNum = squareNum(move.to)
|
||||
subtractColumn(l1, featureIndex(pawn, squareNum(move.from)))
|
||||
board.pieceAt(move.to).foreach(cap => subtractColumn(l1, featureIndex(cap, toNum)))
|
||||
addColumn(l1, featureIndex(Piece(pawn.color, promotedType(promo)), toNum))
|
||||
removePiece(l1, pawn, squareNum(move.from), wkSq, bkSq)
|
||||
board.pieceAt(move.to).foreach(cap => removePiece(l1, cap, toNum, wkSq, bkSq))
|
||||
addPiece(l1, Piece(pawn.color, promotedType(promo)), toNum, wkSq, bkSq)
|
||||
}
|
||||
|
||||
private def promotedType(promo: PromotionPiece): PieceType = promo match
|
||||
@@ -154,7 +177,6 @@ class NNUE(model: NbaiModel):
|
||||
score
|
||||
|
||||
def evaluateAtPlyWithValidation(ply: Int, turn: Color, hash: Long, board: Board): Int =
|
||||
// For debugging: validate that incremental accumulator matches recomputation
|
||||
if validateAccum && ply > 0 && ply % 10 != 0 then
|
||||
val isValid = validateAccumulator(ply, board)
|
||||
if !isValid then System.err.println(s"WARNING: NNUE accumulator diverged at ply $ply")
|
||||
@@ -206,9 +228,23 @@ class NNUE(model: NbaiModel):
|
||||
private val legacyL1 = new Array[Float](accSize)
|
||||
|
||||
def evaluate(context: GameContext): Int =
|
||||
// Match training: for Black-to-move positions, mirror the board (ranks flipped,
|
||||
// colours swapped) so the model always sees from the side-to-move's perspective.
|
||||
// The scoreFromOutput negation then converts back to White's absolute perspective.
|
||||
val (wkSq, bkSq, pieces, turn) =
|
||||
if context.turn == Color.Black then
|
||||
val wk = flipSqNum(bkSqOf(context.board)) // flipped Black king → new "White" king
|
||||
val bk = flipSqNum(wkSqOf(context.board)) // flipped White king → new "Black" king
|
||||
val flipped = context.board.pieces.map { case (sq, p) =>
|
||||
(sq, Piece(p.color.opposite, p.pieceType))
|
||||
}
|
||||
(wk, bk, flipped, Color.Black) // pass Black so scoreFromOutput negates the result
|
||||
else (wkSqOf(context.board), bkSqOf(context.board), context.board.pieces, context.turn)
|
||||
System.arraycopy(model.weights(0).bias, 0, legacyL1, 0, accSize)
|
||||
for (sq, piece) <- context.board.pieces do addColumn(legacyL1, featureIndex(piece, squareNum(sq)))
|
||||
runL2toOutput(legacyL1, context.turn)
|
||||
for (sq, piece) <- pieces do
|
||||
val sqNum = if turn == Color.Black then flipSqNum(squareNum(sq)) else squareNum(sq)
|
||||
addPiece(legacyL1, piece, sqNum, wkSq, bkSq)
|
||||
runL2toOutput(legacyL1, turn)
|
||||
|
||||
def benchmark(): Unit =
|
||||
val context = GameContext.initial
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.bot.config
|
||||
|
||||
import de.nowchess.bot.resource.{JoinTournamentRequest, JoinTournamentResponse}
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[JoinTournamentRequest],
|
||||
classOf[JoinTournamentResponse],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
@@ -32,6 +32,8 @@ final class AlphaBetaSearch(
|
||||
private val nodeCount = AtomicInteger(0)
|
||||
private val ordering = MoveOrdering.OrderingContext()
|
||||
|
||||
def lastNodeCount: Int = nodeCount.get()
|
||||
|
||||
private final case class QuiescenceNode(
|
||||
context: GameContext,
|
||||
ply: Int,
|
||||
@@ -47,6 +49,17 @@ final class AlphaBetaSearch(
|
||||
bestMove(context, maxDepth, Set.empty)
|
||||
|
||||
def bestMove(context: GameContext, maxDepth: Int, excludedRootMoves: Set[Move]): Option[Move] =
|
||||
doDepthSearch(context, maxDepth, excludedRootMoves, Map.empty)
|
||||
|
||||
def bestMove(context: GameContext, maxDepth: Int, excludedRootMoves: Set[Move], hints: Map[Move, Int]): Option[Move] =
|
||||
doDepthSearch(context, maxDepth, excludedRootMoves, hints)
|
||||
|
||||
private def doDepthSearch(
|
||||
context: GameContext,
|
||||
maxDepth: Int,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Move] =
|
||||
tt.clear()
|
||||
ordering.clear()
|
||||
weights.initAccumulator(context)
|
||||
@@ -66,6 +79,7 @@ final class AlphaBetaSearch(
|
||||
ASPIRATION_DELTA,
|
||||
rootHash,
|
||||
excludedRootMoves,
|
||||
hints,
|
||||
)
|
||||
(move.orElse(bestSoFar), score)
|
||||
}
|
||||
@@ -78,6 +92,22 @@ final class AlphaBetaSearch(
|
||||
bestMoveWithTime(context, timeBudgetMs, Set.empty)
|
||||
|
||||
def bestMoveWithTime(context: GameContext, timeBudgetMs: Long, excludedRootMoves: Set[Move]): Option[Move] =
|
||||
doTimedSearch(context, timeBudgetMs, excludedRootMoves, Map.empty)
|
||||
|
||||
def bestMoveWithTime(
|
||||
context: GameContext,
|
||||
timeBudgetMs: Long,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Move] =
|
||||
doTimedSearch(context, timeBudgetMs, excludedRootMoves, hints)
|
||||
|
||||
private def doTimedSearch(
|
||||
context: GameContext,
|
||||
timeBudgetMs: Long,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Move] =
|
||||
tt.clear()
|
||||
ordering.clear()
|
||||
weights.initAccumulator(context)
|
||||
@@ -100,6 +130,7 @@ final class AlphaBetaSearch(
|
||||
ASPIRATION_DELTA,
|
||||
rootHash,
|
||||
excludedRootMoves,
|
||||
hints,
|
||||
)
|
||||
loop(move.orElse(bestSoFar), score, depth + 1, depth)
|
||||
|
||||
@@ -124,14 +155,17 @@ final class AlphaBetaSearch(
|
||||
initialWindow: Int,
|
||||
rootHash: Long,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): (Int, Option[Move]) =
|
||||
val state = SearchState(rootHash, Map(rootHash -> 1))
|
||||
|
||||
@scala.annotation.tailrec
|
||||
def loop(currentAlpha: Int, currentBeta: Int, delta: Int, attempt: Int): (Int, Option[Move]) =
|
||||
if attempt >= 3 || attempt >= depth then search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves)
|
||||
if attempt >= 3 || attempt >= depth then
|
||||
search(context, depth, 0, Window(-INF, INF), state, excludedRootMoves, hints)
|
||||
else
|
||||
val (score, move) = search(context, depth, 0, Window(currentAlpha, currentBeta), state, excludedRootMoves)
|
||||
val (score, move) =
|
||||
search(context, depth, 0, Window(currentAlpha, currentBeta), state, excludedRootMoves, hints)
|
||||
if score > currentAlpha && score < currentBeta then (score, move)
|
||||
else if score <= currentAlpha then
|
||||
loop(score - delta, currentBeta, math.min(delta * 2, ASPIRATION_DELTA_MAX), attempt + 1)
|
||||
@@ -156,12 +190,14 @@ final class AlphaBetaSearch(
|
||||
beta: Int,
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): Option[Int] =
|
||||
val nullCtx = nullMoveContext(context)
|
||||
val nullState = state.advance(ZobristHash.hash(nullCtx))
|
||||
val reductionDepth = math.max(0, depth - 1 - NULL_MOVE_R)
|
||||
weights.copyAccumulator(ply, ply + 1)
|
||||
val (score, _) = search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, excludedRootMoves)
|
||||
val (score, _) =
|
||||
search(nullCtx, reductionDepth, ply + 1, Window(-beta, -beta + 1), nullState, excludedRootMoves, hints)
|
||||
if -score >= beta then Some(beta) else None
|
||||
|
||||
/** Negamax alpha-beta search returning (score, best move). */
|
||||
@@ -172,8 +208,9 @@ final class AlphaBetaSearch(
|
||||
window: Window,
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
hints: Map[Move, Int],
|
||||
): (Int, Option[Move]) =
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves, hints)
|
||||
searchNode(params)
|
||||
|
||||
private def searchNode(params: SearchParams): (Int, Option[Move]) =
|
||||
@@ -235,13 +272,14 @@ final class AlphaBetaSearch(
|
||||
params.window.beta,
|
||||
params.state,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
),
|
||||
)
|
||||
.flatten
|
||||
|
||||
nullResult.map((_, None)).getOrElse {
|
||||
val ttBest = tt.probe(params.state.hash).flatMap(_.bestMove)
|
||||
val ordered = MoveOrdering.sort(params.context, legalMoves, ttBest, params.ply, ordering)
|
||||
val ordered = MoveOrdering.sort(params.context, legalMoves, ttBest, params.ply, ordering, params.rootHints)
|
||||
searchSequential(
|
||||
params.context,
|
||||
params.depth,
|
||||
@@ -250,6 +288,7 @@ final class AlphaBetaSearch(
|
||||
ordered,
|
||||
params.state,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,6 +319,7 @@ final class AlphaBetaSearch(
|
||||
Window(-a - 1, -a),
|
||||
childState,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
val s = -rs
|
||||
if s > a then
|
||||
@@ -290,6 +330,7 @@ final class AlphaBetaSearch(
|
||||
Window(betaNeg, -a),
|
||||
childState,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
-fs
|
||||
else s
|
||||
@@ -301,6 +342,7 @@ final class AlphaBetaSearch(
|
||||
Window(betaNeg, -a),
|
||||
childState,
|
||||
params.excludedRootMoves,
|
||||
params.rootHints,
|
||||
)
|
||||
-rs
|
||||
|
||||
@@ -364,8 +406,9 @@ final class AlphaBetaSearch(
|
||||
ordered: List[Move],
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
): (Int, Option[Move]) =
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves)
|
||||
val params = SearchParams(context, depth, ply, window, state, excludedRootMoves, rootHints)
|
||||
val (bestMove, bestScore, cutoff) = searchLoop(0, 0, LoopAcc(None, -INF, window.alpha), params, ordered)
|
||||
val flag =
|
||||
if cutoff then TTFlag.Lower
|
||||
|
||||
@@ -38,8 +38,10 @@ object MoveOrdering:
|
||||
ttBestMove: Option[Move],
|
||||
ply: Int = 0,
|
||||
ordering: OrderingContext = new OrderingContext(),
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
): Int =
|
||||
if ttBestMove.exists(m => m.from == move.from && m.to == move.to) then Int.MaxValue
|
||||
else if ply == 0 && rootHints.nonEmpty then rootHints.getOrElse(move, Int.MinValue / 2)
|
||||
else
|
||||
move.moveType match
|
||||
case MoveType.Promotion(PromotionPiece.Queen) =>
|
||||
@@ -56,8 +58,9 @@ object MoveOrdering:
|
||||
ttBestMove: Option[Move],
|
||||
ply: Int = 0,
|
||||
ordering: OrderingContext = new OrderingContext(),
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
): List[Move] =
|
||||
moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering))
|
||||
moves.sortBy(m => -score(context, m, ttBestMove, ply, ordering, rootHints))
|
||||
|
||||
private def scoreQuietMove(move: Move, ply: Int, ordering: OrderingContext): Int =
|
||||
val isKiller = ordering.getKillerMoves(ply).exists(k => k.from == move.from && k.to == move.to)
|
||||
|
||||
@@ -14,6 +14,7 @@ final case class SearchParams(
|
||||
window: Window,
|
||||
state: SearchState,
|
||||
excludedRootMoves: Set[Move],
|
||||
rootHints: Map[Move, Int] = Map.empty,
|
||||
)
|
||||
|
||||
final case class SearchState(hash: Long, repetitions: Map[Long, Int]):
|
||||
|
||||
+168
-91
@@ -28,10 +28,10 @@ class TournamentBotGamePlayer:
|
||||
private val log = Logger.getLogger(classOf[TournamentBotGamePlayer])
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var botController: BotController = uninitialized
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var botController: BotController = uninitialized
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
@Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
@@ -43,6 +43,9 @@ class TournamentBotGamePlayer:
|
||||
private val hardestDifficulty = "expert"
|
||||
private val autoJoinIntervalMs = 15000L
|
||||
|
||||
private val gameTerminalStatuses =
|
||||
Set("checkmate", "stalemate", "draw", "resigned", "timeout", "aborted", "finished")
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@volatile private var running = true
|
||||
@volatile private var autoJoinToken: Option[String] = None
|
||||
@@ -82,15 +85,67 @@ class TournamentBotGamePlayer:
|
||||
private def autoJoinScan(): Unit =
|
||||
resolveAutoJoinToken().foreach { token =>
|
||||
TournamentBotConfig.jwtSubject(token).foreach { botId =>
|
||||
openTournaments().foreach { tournamentId =>
|
||||
val open = openTournaments()
|
||||
log.infof("Auto-join scan — server=%s open tournaments=%d bot=%s", autoJoinServerUrl, open.size, botId)
|
||||
open.foreach { tournamentId =>
|
||||
if joinedTournaments.add(tournamentId) then
|
||||
val cfg = TournamentBotConfig(autoJoinServerUrl, tournamentId, token, botId, hardestDifficulty)
|
||||
if join(cfg) then startAsync(cfg)
|
||||
else joinedTournaments.remove(tournamentId)
|
||||
if !joinedOrParticipating(cfg) then joinedTournaments.remove(tournamentId)
|
||||
}
|
||||
playPendingGames(token, botId)
|
||||
}
|
||||
}
|
||||
|
||||
// The tournament-server does not reliably replay gameStart to late subscribers, so we cannot
|
||||
// depend on the event stream to discover games. Poll each joined tournament for our active game.
|
||||
private def playPendingGames(token: String, botId: String): Unit =
|
||||
joinedTournaments.forEach { tournamentId =>
|
||||
val cfg = TournamentBotConfig(autoJoinServerUrl, tournamentId, token, botId, hardestDifficulty)
|
||||
pendingGame(cfg).foreach { (gameId, color) =>
|
||||
if activeGames.add(gameId) then
|
||||
log.infof("Polled active game %s as %s in tournament %s", gameId, color, tournamentId)
|
||||
workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId, color) })
|
||||
}
|
||||
}
|
||||
|
||||
private def pendingGame(cfg: TournamentBotConfig): Option[(String, String)] =
|
||||
for
|
||||
detail <- fetchJson(cfg, target(cfg))
|
||||
if detail.path("status").asText() == "started"
|
||||
round = detail.path("round").asInt(0)
|
||||
if round > 0
|
||||
pairings <- fetchJson(cfg, target(cfg).path("round").path(round.toString)).map(_.path("pairings"))
|
||||
result <- findBotGame(pairings, cfg.botId)
|
||||
yield result
|
||||
|
||||
private def findBotGame(pairings: JsonNode, botId: String): Option[(String, String)] =
|
||||
pairings
|
||||
.elements()
|
||||
.asScala
|
||||
.flatMap { p =>
|
||||
val whiteId = p.path("white").path("id").asText()
|
||||
val blackId = p.path("black").path("id").asText()
|
||||
val color = if whiteId == botId then Some("white") else if blackId == botId then Some("black") else None
|
||||
color.flatMap(c => activeMatch(p.path("matches")).map(gameId => (gameId, c)))
|
||||
}
|
||||
.nextOption()
|
||||
|
||||
private def activeMatch(matches: JsonNode): Option[String] =
|
||||
matches
|
||||
.elements()
|
||||
.asScala
|
||||
.find(m => m.path("gameId").asText().nonEmpty && !(m.has("outcome") && !m.path("outcome").isNull))
|
||||
.map(_.path("gameId").asText())
|
||||
|
||||
private def fetchJson(cfg: TournamentBotConfig, t: jakarta.ws.rs.client.WebTarget): Option[JsonNode] =
|
||||
Try {
|
||||
val response = authed(cfg, t).get()
|
||||
try
|
||||
if response.getStatus == 200 then Some(objectMapper.readTree(response.readEntity(classOf[String])))
|
||||
else None
|
||||
finally response.close()
|
||||
}.getOrElse(None)
|
||||
|
||||
private def resolveAutoJoinToken(): Option[String] =
|
||||
autoJoinToken match
|
||||
case some @ Some(_) => some
|
||||
@@ -100,9 +155,12 @@ class TournamentBotGamePlayer:
|
||||
|
||||
private def openTournaments(): List[String] =
|
||||
Try {
|
||||
val response = client.target(autoJoinServerUrl)
|
||||
.path("api").path("tournament")
|
||||
.request(MediaType.APPLICATION_JSON).get()
|
||||
val response = client
|
||||
.target(autoJoinServerUrl)
|
||||
.path("api")
|
||||
.path("tournament")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.get()
|
||||
if response.getStatus == 200 then
|
||||
val node = objectMapper.readTree(response.readEntity(classOf[String]))
|
||||
response.close()
|
||||
@@ -113,8 +171,8 @@ class TournamentBotGamePlayer:
|
||||
private def resolveToken(difficulty: String): Option[String] =
|
||||
val name = botName(difficulty)
|
||||
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name"
|
||||
registerWithServer(tournamentServiceUrl, name)
|
||||
.orElse(fetchTokenFromAccountService(name))
|
||||
fetchTokenFromAccountService(name)
|
||||
.orElse(registerWithServer(tournamentServiceUrl, name))
|
||||
.map { token =>
|
||||
redis.value(classOf[String]).set(redisKey, token)
|
||||
log.infof("Refreshed bot token for %s — stored in Redis", name)
|
||||
@@ -136,8 +194,11 @@ class TournamentBotGamePlayer:
|
||||
private def registerWithServer(serverUrl: String, name: String): Option[String] =
|
||||
Try {
|
||||
val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":true}"""
|
||||
val response = client.target(serverUrl)
|
||||
.path("api").path("auth").path("register")
|
||||
val response = client
|
||||
.target(serverUrl)
|
||||
.path("api")
|
||||
.path("auth")
|
||||
.path("register")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.post(Entity.entity(body, MediaType.APPLICATION_JSON))
|
||||
val status = response.getStatus
|
||||
@@ -150,11 +211,11 @@ class TournamentBotGamePlayer:
|
||||
log.warnf("Register %s on %s returned status %d: %s", name, serverUrl, status, errBody)
|
||||
response.close()
|
||||
None
|
||||
}.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None }
|
||||
.toOption.flatten
|
||||
}.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None }.toOption.flatten
|
||||
|
||||
private def fetchTokenFromAccountService(name: String): Option[String] =
|
||||
Try(accountServiceClient.getBotToken(name).token).toOption.filter(_.nonEmpty)
|
||||
Try(accountServiceClient.getBotToken(name).token).toOption
|
||||
.filter(_.nonEmpty)
|
||||
.orElse {
|
||||
Try {
|
||||
val allNames = BotController.listBots.map(botName)
|
||||
@@ -180,9 +241,13 @@ class TournamentBotGamePlayer:
|
||||
|
||||
private def fetchRemoteServers(): List[String] =
|
||||
Try {
|
||||
val response = client.target(tournamentServiceUrl)
|
||||
.path("api").path("tournament").path("servers")
|
||||
.request(MediaType.APPLICATION_JSON).get()
|
||||
val response = client
|
||||
.target(tournamentServiceUrl)
|
||||
.path("api")
|
||||
.path("tournament")
|
||||
.path("servers")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.get()
|
||||
if response.getStatus == 200 then
|
||||
val node = objectMapper.readTree(response.readEntity(classOf[String]))
|
||||
response.close()
|
||||
@@ -193,7 +258,11 @@ class TournamentBotGamePlayer:
|
||||
private def parkOnAccountService(serverUrl: String, difficulty: String, token: String): Unit =
|
||||
Try {
|
||||
val body = s"""{"name":"${botName(difficulty)}"}"""
|
||||
val response = client.target(serverUrl).path("api").path("account").path("bots")
|
||||
val response = client
|
||||
.target(serverUrl)
|
||||
.path("api")
|
||||
.path("account")
|
||||
.path("bots")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", s"Bearer $token")
|
||||
.post(Entity.entity(body, MediaType.APPLICATION_JSON))
|
||||
@@ -207,7 +276,10 @@ class TournamentBotGamePlayer:
|
||||
private def parkOnTournamentServer(serverUrl: String, name: String, token: String): Unit =
|
||||
Try {
|
||||
val body = s"""{"name":"${name.replace("\"", "\\\"")}"}"""
|
||||
val response = client.target(serverUrl).path("api").path("bots")
|
||||
val response = client
|
||||
.target(serverUrl)
|
||||
.path("api")
|
||||
.path("bots")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", s"Bearer $token")
|
||||
.post(Entity.entity(body, MediaType.APPLICATION_JSON))
|
||||
@@ -225,10 +297,11 @@ class TournamentBotGamePlayer:
|
||||
botToken: Option[String],
|
||||
difficulty: String,
|
||||
): Either[String, String] =
|
||||
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}"
|
||||
val resolvedToken = botToken.filter(_.nonEmpty)
|
||||
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}"
|
||||
val resolvedToken = botToken
|
||||
.filter(_.nonEmpty)
|
||||
.orElse(Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty))
|
||||
.orElse(System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty))
|
||||
.orElse(resolveToken(difficulty))
|
||||
resolvedToken match
|
||||
case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured")
|
||||
case Some(token) =>
|
||||
@@ -236,7 +309,7 @@ class TournamentBotGamePlayer:
|
||||
case None => Left("Invalid bot token — could not extract subject")
|
||||
case Some(botId) =>
|
||||
val cfg = TournamentBotConfig(tournamentServiceUrl, tournamentId, token, botId, difficulty)
|
||||
if join(cfg) then
|
||||
if joinedOrParticipating(cfg) then
|
||||
startAsync(cfg)
|
||||
Right(botId)
|
||||
else Left("Failed to join tournament")
|
||||
@@ -259,15 +332,18 @@ class TournamentBotGamePlayer:
|
||||
case Failure(ex) => log.warnf(ex, "Tournament event stream dropped — reconnecting"); sleep(5000)
|
||||
case Success(_) => sleep(2000)
|
||||
|
||||
private def join(cfg: TournamentBotConfig): Boolean =
|
||||
// 200 = joined, 409 = already a participant (e.g. after a restart) — both mean "play this tournament".
|
||||
private def joinedOrParticipating(cfg: TournamentBotConfig): Boolean =
|
||||
Try {
|
||||
val response = authed(cfg, target(cfg).path("join"))
|
||||
.post(Entity.entity("", MediaType.APPLICATION_JSON))
|
||||
val ok = response.getStatus == 200
|
||||
if ok then log.infof("Joined tournament %s", cfg.tournamentId)
|
||||
else log.errorf("Failed to join tournament %s — status %d", cfg.tournamentId, response.getStatus)
|
||||
val status = response.getStatus
|
||||
response.close()
|
||||
ok
|
||||
status match
|
||||
case 200 => log.infof("Joined tournament %s", cfg.tournamentId); true
|
||||
case 409 => log.infof("Already in tournament %s — resuming", cfg.tournamentId); true
|
||||
case other =>
|
||||
log.errorf("Failed to join tournament %s — status %d", cfg.tournamentId, other); false
|
||||
}.getOrElse { log.error("Join request failed"); false }
|
||||
|
||||
private def streamEvents(cfg: TournamentBotConfig): Unit =
|
||||
@@ -282,69 +358,79 @@ class TournamentBotGamePlayer:
|
||||
log.infof("Listening to tournament %s event stream", cfg.tournamentId)
|
||||
forEachLine(response.readEntity(classOf[InputStream])): line =>
|
||||
parse(line).foreach: node =>
|
||||
if node.path("type").asText() == "gameStart" then
|
||||
onGameStart(cfg, node.path("gameId").asText(), node.path("color").asText())
|
||||
if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText())
|
||||
|
||||
private def onGameStart(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
|
||||
if gameId.nonEmpty && color.nonEmpty && activeGames.add(gameId) then
|
||||
workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId, color) })
|
||||
()
|
||||
private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit =
|
||||
if gameId.isEmpty then ()
|
||||
else
|
||||
log.infof("gameStart received — tournament=%s game=%s bot=%s", cfg.tournamentId, gameId, cfg.botId)
|
||||
resolveColor(cfg, gameId) match
|
||||
case None => log.infof("Skipping game %s — bot %s is not a participant", gameId, cfg.botId)
|
||||
case Some(color) =>
|
||||
if activeGames.add(gameId) then
|
||||
log.infof("Joining game %s as %s", gameId, color)
|
||||
workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId, color) })
|
||||
()
|
||||
|
||||
private def resolveColor(cfg: TournamentBotConfig, gameId: String): Option[String] =
|
||||
fetchGame(cfg, gameId).flatMap { node =>
|
||||
val whiteId = node.path("white").path("id").asText()
|
||||
val blackId = node.path("black").path("id").asText()
|
||||
if whiteId == cfg.botId then Some("white")
|
||||
else if blackId == cfg.botId then Some("black")
|
||||
else None
|
||||
}
|
||||
|
||||
private def fetchGame(cfg: TournamentBotConfig, gameId: String): Option[JsonNode] =
|
||||
Try {
|
||||
val response = authed(cfg, target(cfg).path("game").path(gameId)).get()
|
||||
try
|
||||
if response.getStatus == 200 then Some(objectMapper.readTree(response.readEntity(classOf[String])))
|
||||
else { log.warnf("Game detail %s returned status %d", gameId, response.getStatus); None }
|
||||
finally response.close()
|
||||
}.getOrElse(None)
|
||||
|
||||
private def playGame(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
|
||||
Try {
|
||||
log.infof("Playing game %s as %s", gameId, color)
|
||||
openGameStream(cfg, gameId).foreach(consumeGameStream(cfg, gameId, color, _))
|
||||
pollGameLoop(cfg, gameId, color)
|
||||
activeGames.remove(gameId)
|
||||
} match
|
||||
case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId)
|
||||
case Success(_) => ()
|
||||
|
||||
private def consumeGameStream(cfg: TournamentBotConfig, gameId: String, color: String, stream: InputStream): Unit =
|
||||
val reader = new BufferedReader(new InputStreamReader(stream))
|
||||
// The native JAX-RS client buffers streaming responses, so reading the NDJSON game stream blocks
|
||||
// forever. Poll the game state with plain GETs (which work) and move when it is our turn.
|
||||
private def pollGameLoop(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
|
||||
// scalafix:off DisableSyntax.var
|
||||
var done = false
|
||||
var done = false
|
||||
var lastFen = ""
|
||||
// scalafix:on DisableSyntax.var
|
||||
Iterator
|
||||
.continually(reader.readLine())
|
||||
.map(Option(_))
|
||||
.takeWhile(opt => opt.isDefined && running && !done)
|
||||
.flatten
|
||||
.foreach { line =>
|
||||
parse(line).foreach: node =>
|
||||
node.path("type").asText() match
|
||||
case "gameState" =>
|
||||
maybeMove(
|
||||
cfg,
|
||||
gameId,
|
||||
color,
|
||||
node.path("turn").asText(),
|
||||
node.path("status").asText(),
|
||||
node.path("fen").asText(),
|
||||
)
|
||||
case "move" =>
|
||||
maybeMove(cfg, gameId, color, node.path("turn").asText(), "ongoing", node.path("fen").asText())
|
||||
case "gameEnd" =>
|
||||
log.infof(
|
||||
"Game %s ended — status=%s winner=%s",
|
||||
gameId,
|
||||
node.path("status").asText(),
|
||||
node.path("winner").asText(),
|
||||
); done = true
|
||||
case _ => ()
|
||||
}
|
||||
|
||||
private def maybeMove(
|
||||
cfg: TournamentBotConfig,
|
||||
gameId: String,
|
||||
color: String,
|
||||
turn: String,
|
||||
status: String,
|
||||
fen: String,
|
||||
): Unit =
|
||||
if turn == color && status == "ongoing" && fen.nonEmpty then
|
||||
computeUci(cfg, fen) match
|
||||
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
|
||||
case Some(uci) => submitMove(cfg, gameId, uci)
|
||||
while running && !done do
|
||||
fetchJson(cfg, target(cfg).path("game").path(gameId)) match
|
||||
case None => sleep(2000)
|
||||
case Some(node) =>
|
||||
val status = node.path("status").asText()
|
||||
if gameTerminalStatuses.contains(status) then
|
||||
log.infof("Game %s ended — status=%s", gameId, status); done = true
|
||||
else
|
||||
// TEMP: tournament-server reports wrong color in pairings (everyone white).
|
||||
// The game endpoint white/black ids are correct, so derive our color from it.
|
||||
val whiteId = node.path("white").path("id").asText()
|
||||
val blackId = node.path("black").path("id").asText()
|
||||
val myColor =
|
||||
if whiteId == cfg.botId then "white"
|
||||
else if blackId == cfg.botId then "black"
|
||||
else color
|
||||
val turn = node.path("turn").asText()
|
||||
val fen = node.path("fen").asText()
|
||||
if turn == myColor && status == "ongoing" && fen.nonEmpty && fen != lastFen then
|
||||
lastFen = fen
|
||||
log.infof("Our turn in game %s — computing move (fen=%s)", gameId, fen)
|
||||
computeUci(cfg, fen) match
|
||||
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
|
||||
case Some(uci) => submitMove(cfg, gameId, uci)
|
||||
sleep(1000)
|
||||
|
||||
private def computeUci(cfg: TournamentBotConfig, fen: String): Option[String] =
|
||||
FenParser.parseFen(fen) match
|
||||
@@ -362,15 +448,6 @@ class TournamentBotGamePlayer:
|
||||
case Failure(ex) => log.errorf(ex, "Error submitting move %s in game %s", uci, gameId)
|
||||
case Success(_) => ()
|
||||
|
||||
private def openGameStream(cfg: TournamentBotConfig, gameId: String): Option[InputStream] =
|
||||
Try {
|
||||
val response = authed(cfg, target(cfg).path("game").path(gameId).path("stream"))
|
||||
.header("Accept", "application/x-ndjson")
|
||||
.get()
|
||||
if response.getStatus == 200 then Some(response.readEntity(classOf[InputStream]))
|
||||
else { log.warnf("Game stream %s returned status %d", gameId, response.getStatus); response.close(); None }
|
||||
}.getOrElse(None)
|
||||
|
||||
private def engine(cfg: TournamentBotConfig): Bot =
|
||||
botController.getBot(cfg.difficulty).orElse(botController.getBot("medium")).get
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
|
||||
import java.io.{DataInputStream, FileInputStream}
|
||||
import java.io.{DataInputStream, FileInputStream, InputStream}
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import scala.collection.mutable
|
||||
import scala.util.Random
|
||||
|
||||
/** Reads a Polyglot opening book (.bin file) and probes it for moves.
|
||||
*
|
||||
@@ -16,24 +16,11 @@ import scala.util.Random
|
||||
* - weight: 2 bytes (Short) — move weight (higher = preferred)
|
||||
* - learn: 4 bytes (Int) — learning data (unused)
|
||||
*/
|
||||
final class PolyglotBook(path: String):
|
||||
|
||||
private val entries: Map[Long, Vector[BookEntry]] =
|
||||
try {
|
||||
val r = loadBookFile(path)
|
||||
println(s"Book loaded successfully. ${r.size} entries found.")
|
||||
r
|
||||
} catch
|
||||
case e: Exception =>
|
||||
println(s"Error loading book: $e")
|
||||
// Gracefully fail: return empty map if book cannot be loaded
|
||||
// This allows the bot to work even if the book file is missing
|
||||
scala.collection.immutable.Map.empty
|
||||
final class PolyglotBook private (entries: Map[Long, Vector[BookEntry]]):
|
||||
|
||||
/** Probe the book for a move in the given position. Returns a weighted random move, or None if not in book. */
|
||||
def probe(context: GameContext): Option[Move] =
|
||||
val hash = PolyglotHash.hash(context)
|
||||
println(f"0x$hash%016X")
|
||||
entries.get(hash).flatMap { bookEntries =>
|
||||
if bookEntries.isEmpty then None
|
||||
else
|
||||
@@ -41,24 +28,6 @@ final class PolyglotBook(path: String):
|
||||
decodeMove(entry.move, context)
|
||||
}
|
||||
|
||||
private def loadBookFile(path: String): Map[Long, Vector[BookEntry]] =
|
||||
val input = DataInputStream(FileInputStream(path))
|
||||
try
|
||||
val result = mutable.Map[Long, Vector[BookEntry]]()
|
||||
while input.available() > 0 do
|
||||
val key = input.readLong()
|
||||
val move = input.readShort()
|
||||
val weight = input.readShort()
|
||||
input.readInt() // learning data (unused)
|
||||
|
||||
val entry = BookEntry(key, move, weight)
|
||||
result.updateWith(key) {
|
||||
case Some(entries) => Some(entries :+ entry)
|
||||
case None => Some(Vector(entry))
|
||||
}
|
||||
result.toMap
|
||||
finally input.close()
|
||||
|
||||
/** Decode a packed Polyglot move short into an Option[Move].
|
||||
*
|
||||
* Bit layout of the move Short:
|
||||
@@ -124,7 +93,7 @@ final class PolyglotBook(path: String):
|
||||
if entries.length == 1 then entries.head
|
||||
else
|
||||
val totalWeight = entries.map(_.weight).sum
|
||||
val pick = Random.nextInt(totalWeight.max(1)) // NOSONAR
|
||||
val pick = ThreadLocalRandom.current().nextInt(totalWeight.max(1)) // NOSONAR
|
||||
|
||||
@scala.annotation.tailrec
|
||||
def select(remaining: Int, idx: Int): BookEntry =
|
||||
@@ -134,4 +103,48 @@ final class PolyglotBook(path: String):
|
||||
|
||||
select(pick, 0)
|
||||
|
||||
object PolyglotBook:
|
||||
|
||||
/** Load a book from a filesystem path. Fails gracefully to an empty book. */
|
||||
def apply(path: String): PolyglotBook =
|
||||
safeLoad(s"file $path")(FileInputStream(path))
|
||||
|
||||
/** Load a book from a classpath resource (native-image safe: the resource is embedded in the binary, so no file must
|
||||
* be mounted into the pod).
|
||||
*/
|
||||
def fromResource(name: String): PolyglotBook =
|
||||
Option(getClass.getResourceAsStream(name)) match
|
||||
case Some(stream) => safeLoad(s"resource $name")(stream)
|
||||
case None =>
|
||||
println(s"Error loading book: resource $name not found on classpath")
|
||||
new PolyglotBook(Map.empty)
|
||||
|
||||
private def safeLoad(source: String)(stream: => InputStream): PolyglotBook =
|
||||
try
|
||||
val entries = parse(stream)
|
||||
println(s"Book loaded successfully from $source. ${entries.size} entries found.")
|
||||
new PolyglotBook(entries)
|
||||
catch
|
||||
case e: Exception =>
|
||||
println(s"Error loading book from $source: $e")
|
||||
new PolyglotBook(Map.empty)
|
||||
|
||||
private def parse(stream: InputStream): Map[Long, Vector[BookEntry]] =
|
||||
val input = DataInputStream(stream)
|
||||
try
|
||||
val result = mutable.Map[Long, Vector[BookEntry]]()
|
||||
while input.available() > 0 do
|
||||
val key = input.readLong()
|
||||
val move = input.readShort()
|
||||
val weight = input.readShort()
|
||||
input.readInt() // learning data (unused)
|
||||
|
||||
val entry = BookEntry(key, move, weight)
|
||||
result.updateWith(key) {
|
||||
case Some(entries) => Some(entries :+ entry)
|
||||
case None => Some(Vector(entry))
|
||||
}
|
||||
result.toMap
|
||||
finally input.close()
|
||||
|
||||
private case class BookEntry(key: Long, move: Short, weight: Int)
|
||||
|
||||
@@ -312,6 +312,24 @@ class AlphaBetaSearchTest extends AnyFunSuite with Matchers:
|
||||
val search = AlphaBetaSearch(qRules, weights = ZeroEval)
|
||||
search.bestMove(GameContext.initial, maxDepth = 1) should be(Some(rootMove))
|
||||
|
||||
test("bestMove with root hints returns a valid move without regression"):
|
||||
val context = GameContext.initial
|
||||
val legalMoves = DefaultRules.allLegalMoves(context)
|
||||
val hints = legalMoves.zipWithIndex.map { case (m, i) => m -> (legalMoves.length - i) }.toMap
|
||||
val withHints = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
.bestMove(context, maxDepth = 2, Set.empty, hints)
|
||||
withHints should not be None
|
||||
legalMoves should contain(withHints.get)
|
||||
|
||||
test("bestMoveWithTime with root hints returns a valid move without regression"):
|
||||
val context = GameContext.initial
|
||||
val legalMoves = DefaultRules.allLegalMoves(context)
|
||||
val hints = legalMoves.zipWithIndex.map { case (m, i) => m -> (legalMoves.length - i) }.toMap
|
||||
val withHints = AlphaBetaSearch(DefaultRules, weights = EvaluationClassic)
|
||||
.bestMoveWithTime(context, 500L, Set.empty, hints)
|
||||
withHints should not be None
|
||||
legalMoves should contain(withHints.get)
|
||||
|
||||
test("quiescence depth-limit in-check branch is exercised"):
|
||||
val rootMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||
val capMove = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true))
|
||||
|
||||
@@ -80,76 +80,58 @@ class HybridBotTest extends AnyFunSuite with Matchers:
|
||||
bot.apply(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
|
||||
finally Files.deleteIfExists(tempFile)
|
||||
|
||||
test("HybridBot reports veto when classical and NNUE differ above threshold"):
|
||||
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||
val oneMoveRules = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
|
||||
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.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
||||
// 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())
|
||||
|
||||
object LowNnue extends Evaluation:
|
||||
val CHECKMATE_SCORE: Int = 10_000_000
|
||||
val DRAW_SCORE: Int = 0
|
||||
def evaluate(context: GameContext): Int = 0
|
||||
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)
|
||||
|
||||
object HighClassic extends Evaluation:
|
||||
val CHECKMATE_SCORE: Int = 10_000_000
|
||||
val DRAW_SCORE: Int = 0
|
||||
def evaluate(context: GameContext): Int = 10_000
|
||||
// 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 = oneMoveRules,
|
||||
nnueEvaluation = LowNnue,
|
||||
rules = vetoRules,
|
||||
nnueEvaluation = DistrustfulNnue,
|
||||
classicalEvaluation = HighClassic,
|
||||
vetoReporter = _ => reported.set(true),
|
||||
)
|
||||
|
||||
bot.apply(GameContext.initial) should be(Some(forcedMove))
|
||||
bot.apply(GameContext.initial) should be(Some(altMove))
|
||||
reported.get should be(true)
|
||||
|
||||
test("HybridBot default veto reporter prints when threshold is exceeded"):
|
||||
val forcedMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R3), MoveType.Normal())
|
||||
val oneMoveRules = new RuleSet:
|
||||
def candidateMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||
def legalMoves(context: GameContext)(square: Square): List[Move] = List(forcedMove)
|
||||
def allLegalMoves(context: GameContext): List[Move] = List(forcedMove)
|
||||
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.copy(turn = context.turn.opposite, moves = context.moves :+ move)
|
||||
|
||||
object LowNnue extends Evaluation:
|
||||
val CHECKMATE_SCORE: Int = 10_000_000
|
||||
val DRAW_SCORE: Int = 0
|
||||
def evaluate(context: GameContext): Int = 0
|
||||
|
||||
object HighClassic extends Evaluation:
|
||||
val CHECKMATE_SCORE: Int = 10_000_000
|
||||
val DRAW_SCORE: Int = 0
|
||||
def evaluate(context: GameContext): Int = 10_000
|
||||
|
||||
val bot = HybridBot(
|
||||
BotDifficulty.Easy,
|
||||
rules = oneMoveRules,
|
||||
nnueEvaluation = LowNnue,
|
||||
rules = vetoRules,
|
||||
nnueEvaluation = DistrustfulNnue,
|
||||
classicalEvaluation = HighClassic,
|
||||
)
|
||||
|
||||
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
|
||||
bot.apply(GameContext.initial)
|
||||
}
|
||||
printed should be(Some(forcedMove))
|
||||
printed should be(Some(altMove))
|
||||
|
||||
@@ -217,3 +217,60 @@ class MoveOrderingTest extends AnyFunSuite with Matchers:
|
||||
val castle = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||
|
||||
MoveOrdering.score(context, castle, None) should be(0)
|
||||
|
||||
test("root hints override capture heuristics at ply 0"):
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
|
||||
Square(File.E, Rank.R5) -> Piece.BlackPawn,
|
||||
Square(File.D, Rank.R5) -> Piece.BlackRook,
|
||||
),
|
||||
)
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val quietMove = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6))
|
||||
val rookCapture = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val hints = Map(quietMove -> 500, rookCapture -> 100)
|
||||
|
||||
MoveOrdering.score(context, quietMove, None, ply = 0, rootHints = hints) should equal(500)
|
||||
MoveOrdering.score(context, rookCapture, None, ply = 0, rootHints = hints) should equal(100)
|
||||
MoveOrdering.score(context, rookCapture, None, ply = 0, rootHints = hints) should be <
|
||||
MoveOrdering.score(context, quietMove, None, ply = 0, rootHints = hints)
|
||||
|
||||
test("root hints ignored at ply > 0"):
|
||||
val board = Board(Map(Square(File.E, Rank.R4) -> Piece.WhiteQueen, Square(File.E, Rank.R5) -> Piece.BlackPawn))
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val capture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
|
||||
val quiet = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R4))
|
||||
val hints = Map(quiet -> 99999, capture -> -99999)
|
||||
|
||||
val captureScore = MoveOrdering.score(context, capture, None, ply = 1, rootHints = hints)
|
||||
val quietScore = MoveOrdering.score(context, quiet, None, ply = 1, rootHints = hints)
|
||||
captureScore should be > quietScore
|
||||
|
||||
test("move absent from root hints gets Int.MinValue / 2 fallback"):
|
||||
val board = Board(Map(Square(File.E, Rank.R4) -> Piece.WhiteQueen))
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val move1 = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6))
|
||||
val move2 = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5))
|
||||
val hints = Map(move1 -> 0)
|
||||
|
||||
MoveOrdering.score(context, move2, None, ply = 0, rootHints = hints) should equal(Int.MinValue / 2)
|
||||
|
||||
test("sort uses root hints at ply 0 to reorder moves"):
|
||||
val board = Board(
|
||||
Map(
|
||||
Square(File.E, Rank.R4) -> Piece.WhiteQueen,
|
||||
Square(File.E, Rank.R5) -> Piece.BlackPawn,
|
||||
Square(File.D, Rank.R5) -> Piece.BlackRook,
|
||||
),
|
||||
)
|
||||
val context = GameContext.initial.withBoard(board).withTurn(Color.White)
|
||||
val rookCapture = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val pawnCapture = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true))
|
||||
val quiet = Move(Square(File.E, Rank.R4), Square(File.E, Rank.R6))
|
||||
val hints = Map(quiet -> 9999, pawnCapture -> 500, rookCapture -> 100)
|
||||
|
||||
val sorted = MoveOrdering.sort(context, List(rookCapture, pawnCapture, quiet), None, ply = 0, rootHints = hints)
|
||||
sorted.head should equal(quiet)
|
||||
sorted(1) should equal(pawnCapture)
|
||||
sorted(2) should equal(rookCapture)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=28
|
||||
MINOR=38
|
||||
PATCH=0
|
||||
|
||||
@@ -74,3 +74,66 @@
|
||||
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
|
||||
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
|
||||
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* NCS-121 pipeline for tournament ([#68](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/68)) ([145f467](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/145f4676483f92bfe6f2d9ca40e2cb4200982e87))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **reflection:** add GameWritebackEventDto to native reflection configuration ([1aee39c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1aee39c1ad286984501ac4b47da2b72d60b58a6f))
|
||||
* **reflection:** add native reflection configuration for tournament classes ([65bc6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/65bc6a759937543df2d29905688bfa9e68d0c9d4))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** remove dynamic server add/remove endpoints ([6d06edd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6d06edda69a50de65cd9efa27f26a4cc6b437f9d))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
|
||||
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
|
||||
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
|
||||
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* NCS-121 pipeline for tournament ([#68](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/68)) ([145f467](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/145f4676483f92bfe6f2d9ca40e2cb4200982e87))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **reflection:** add GameWritebackEventDto to native reflection configuration ([1aee39c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1aee39c1ad286984501ac4b47da2b72d60b58a6f))
|
||||
* **reflection:** add native reflection configuration for tournament classes ([65bc6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/65bc6a759937543df2d29905688bfa9e68d0c9d4))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** remove dynamic server add/remove endpoints ([6d06edd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6d06edda69a50de65cd9efa27f26a4cc6b437f9d))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
|
||||
* **tournament:** sync native-server participants and route start ([#78](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/78)) ([1f4e9c8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1f4e9c8498f55d95ab48758df60c7618445bf6ca))
|
||||
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
|
||||
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
|
||||
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
||||
## (2026-06-23)
|
||||
|
||||
### Features
|
||||
|
||||
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
|
||||
* NCS-121 pipeline for tournament ([#68](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/68)) ([145f467](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/145f4676483f92bfe6f2d9ca40e2cb4200982e87))
|
||||
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
|
||||
* **reflection:** add GameWritebackEventDto to native reflection configuration ([1aee39c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1aee39c1ad286984501ac4b47da2b72d60b58a6f))
|
||||
* **reflection:** add native reflection configuration for tournament classes ([65bc6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/65bc6a759937543df2d29905688bfa9e68d0c9d4))
|
||||
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
|
||||
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
|
||||
* **tournament:** remove dynamic server add/remove endpoints ([6d06edd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6d06edda69a50de65cd9efa27f26a4cc6b437f9d))
|
||||
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **tournament:** mirror bot join onto native twin ([7664042](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76640421930c26a9260da002c90e2966b97a57a4))
|
||||
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
|
||||
* **tournament:** sync native-server participants and route start ([#78](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/78)) ([1f4e9c8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1f4e9c8498f55d95ab48758df60c7618445bf6ca))
|
||||
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
|
||||
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
|
||||
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.time.Instant
|
||||
@Entity
|
||||
@Table(name = "tournaments")
|
||||
class Tournament:
|
||||
// scalafix:off DisableSyntax.var
|
||||
// scalafix:off
|
||||
@Id
|
||||
var id: String = uninitialized
|
||||
|
||||
@@ -33,4 +33,7 @@ class Tournament:
|
||||
|
||||
@Column(nullable = true)
|
||||
var originServerUrl: String = null
|
||||
|
||||
@Column(nullable = true)
|
||||
var nativeTournamentId: String = null
|
||||
// scalafix:on
|
||||
|
||||
+88
-27
@@ -52,9 +52,9 @@ class TournamentResource:
|
||||
@PermitAll
|
||||
def list(): Response =
|
||||
val (created, started, finished) = tournamentService.list()
|
||||
val internalCreated = created.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
||||
val internalStarted = started.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
||||
val internalFinished = finished.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t)))
|
||||
val internalCreated = created.map(nativeOverlay)
|
||||
val internalStarted = started.map(nativeOverlay)
|
||||
val internalFinished = finished.map(nativeOverlay)
|
||||
|
||||
val (extCreated, extStarted, extFinished) = registry
|
||||
.serverUrls()
|
||||
@@ -96,7 +96,7 @@ class TournamentResource:
|
||||
val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated)
|
||||
val t = tournamentService.create(userId, form)
|
||||
selfUrl.ifPresent { url =>
|
||||
registry.serverUrls().foreach { remoteUrl =>
|
||||
registry.serverUrls().filterNot(externalClient.isNativeServer).foreach { remoteUrl =>
|
||||
if !externalClient.replicateTournament(remoteUrl, toReplicateRequest(t), url) then
|
||||
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
|
||||
}
|
||||
@@ -115,8 +115,52 @@ class TournamentResource:
|
||||
"rated" -> t.rated.toString,
|
||||
),
|
||||
)
|
||||
if !externalClient.publishNative(nativeServerUrl, directorName, form) then
|
||||
log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl)
|
||||
externalClient.publishNative(nativeServerUrl, directorName, form) match
|
||||
case Some(nativeId) => tournamentService.setNativeTournamentId(t.id, nativeId)
|
||||
case None => log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl)
|
||||
|
||||
// Mirror a bot join onto the native twin so it surfaces in the UI, which reads participant and
|
||||
// standings fields from the native server (see nativeOverlay).
|
||||
private def joinNativeTwin(id: String, botName: String): Unit =
|
||||
if nativeServerUrl.nonEmpty then
|
||||
tournamentService.get(id).flatMap(nativeIdFor).foreach { nativeId =>
|
||||
if externalClient.joinNativeAsBot(nativeServerUrl, nativeId, botName) then
|
||||
log.infof("Joined bot %s on native twin %s of tournament %s", botName, nativeId, id)
|
||||
else log.warnf("Failed to join bot %s on native twin %s of tournament %s", botName, nativeId, id)
|
||||
}
|
||||
|
||||
// Resolve the native-server twin of a local tournament. Backfills the stored id by matching
|
||||
// fullName against the native list for tournaments created before the id was captured.
|
||||
private def nativeIdFor(t: de.nowchess.tournament.domain.Tournament): Option[String] =
|
||||
if nativeServerUrl.isEmpty then None
|
||||
else
|
||||
Option(t.nativeTournamentId).filter(_.nonEmpty).orElse {
|
||||
val found = externalClient
|
||||
.fetchList(nativeServerUrl)
|
||||
.flatMap { node =>
|
||||
Seq("created", "started", "finished").iterator
|
||||
.flatMap(k => node.path(k).elements().asScala)
|
||||
.find(_.path("fullName").asText() == t.fullName)
|
||||
.map(_.path("id").asText())
|
||||
.filter(_.nonEmpty)
|
||||
}
|
||||
found.foreach(id => tournamentService.setNativeTournamentId(t.id, id))
|
||||
found
|
||||
}
|
||||
|
||||
// Overlay live participant/standings/status fields from the native twin onto a local DTO so
|
||||
// bots that joined directly on the native server are reflected in NowChess.
|
||||
private def nativeOverlay(t: de.nowchess.tournament.domain.Tournament): JsonNode =
|
||||
val standings = tournamentService.getStandings(t.id)
|
||||
val dto = objectMapper.valueToTree[JsonNode](tournamentService.toDto(t, standings))
|
||||
nativeIdFor(t).flatMap(nid => externalClient.fetch(nativeServerUrl, nid)) match
|
||||
case Some(native) =>
|
||||
val merged = dto.deepCopy[com.fasterxml.jackson.databind.node.ObjectNode]()
|
||||
Seq("nbPlayers", "standing", "status", "round", "winner").foreach { field =>
|
||||
if native.has(field) then merged.set(field, native.get(field))
|
||||
}
|
||||
merged
|
||||
case None => dto
|
||||
|
||||
private def encodeForm(params: Map[String, String]): String =
|
||||
params
|
||||
@@ -132,8 +176,7 @@ class TournamentResource:
|
||||
def get(@PathParam("id") id: String): Response =
|
||||
tournamentService.get(id) match
|
||||
case Some(t) =>
|
||||
val standings = tournamentService.getStandings(id)
|
||||
Response.ok(tournamentService.toDto(t, standings)).build()
|
||||
Response.ok(nativeOverlay(t)).build()
|
||||
case None =>
|
||||
resolveServer(id)
|
||||
.flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build()))
|
||||
@@ -179,19 +222,26 @@ class TournamentResource:
|
||||
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/start", auth)
|
||||
Response.status(status).entity(body).build()
|
||||
case None =>
|
||||
tournamentService.start(id, userId) match
|
||||
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||
case Left(error) =>
|
||||
error match
|
||||
case TournamentError.NotFound(_) =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
resolveServer(id)
|
||||
.map { url =>
|
||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth)
|
||||
Response.status(status).entity(body).build()
|
||||
}
|
||||
.getOrElse(errorResponse(error))
|
||||
case _ => errorResponse(error)
|
||||
tournamentService.get(id).flatMap(nativeIdFor) match
|
||||
case Some(nativeId) =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
val (status, body) = externalClient.proxyPost(nativeServerUrl, s"api/tournament/$nativeId/start", auth)
|
||||
if status / 100 == 2 then tournamentService.markStatus(id, "started")
|
||||
Response.status(status).entity(body).build()
|
||||
case None =>
|
||||
tournamentService.start(id, userId) match
|
||||
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||
case Left(error) =>
|
||||
error match
|
||||
case TournamentError.NotFound(_) =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
resolveServer(id)
|
||||
.map { url =>
|
||||
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth)
|
||||
Response.status(status).entity(body).build()
|
||||
}
|
||||
.getOrElse(errorResponse(error))
|
||||
case _ => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/join")
|
||||
@@ -210,7 +260,14 @@ class TournamentResource:
|
||||
val botId = Option(jwt.getSubject).getOrElse("")
|
||||
val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId)
|
||||
tournamentService.join(id, botId, botName) match
|
||||
case Right(_) => Response.ok(OkDto()).build()
|
||||
case Right(_) =>
|
||||
joinNativeTwin(id, botName)
|
||||
Response.ok(OkDto()).build()
|
||||
// Already in the NowChess participant list but possibly never mirrored onto the native
|
||||
// twin (where the UI reads participants from) — make the native join idempotent.
|
||||
case Left(TournamentError.AlreadyJoined) =>
|
||||
joinNativeTwin(id, botName)
|
||||
Response.ok(OkDto()).build()
|
||||
case Left(error) =>
|
||||
error match
|
||||
case TournamentError.NotFound(_) =>
|
||||
@@ -317,7 +374,8 @@ class TournamentResource:
|
||||
tournamentService.get(id) match
|
||||
case Some(t) if Option(t.originServerUrl).isDefined =>
|
||||
val auth = Option(headers.getHeaderString("Authorization"))
|
||||
externalClient.proxyGetStream(t.originServerUrl, s"api/tournament/$id/stream", auth)
|
||||
externalClient
|
||||
.proxyGetStream(t.originServerUrl, s"api/tournament/$id/stream", auth)
|
||||
.map { inputStream =>
|
||||
Response
|
||||
.ok(new StreamingOutput {
|
||||
@@ -334,10 +392,12 @@ class TournamentResource:
|
||||
.`type`("application/x-ndjson")
|
||||
.build()
|
||||
}
|
||||
.getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id stream unavailable")).build())
|
||||
.getOrElse(
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id stream unavailable")).build(),
|
||||
)
|
||||
case Some(_) =>
|
||||
val botId = Option(jwt.getSubject).getOrElse("")
|
||||
val queue = new java.util.concurrent.LinkedBlockingQueue[Option[String]]()
|
||||
val queue = new java.util.concurrent.LinkedBlockingQueue[Option[String]]()
|
||||
val emitter = new io.smallrye.mutiny.subscription.MultiEmitter[String] {
|
||||
def emit(item: String): io.smallrye.mutiny.subscription.MultiEmitter[String] =
|
||||
queue.put(Some(item)); this
|
||||
@@ -358,7 +418,7 @@ class TournamentResource:
|
||||
var cont = true
|
||||
while cont do
|
||||
queue.take() match
|
||||
case None => cont = false
|
||||
case None => cont = false
|
||||
case Some(line) =>
|
||||
output.write((line + "\n").getBytes("UTF-8"))
|
||||
output.flush()
|
||||
@@ -440,7 +500,8 @@ class TournamentResource:
|
||||
.getOrElse(Response.status(Response.Status.NOT_FOUND).build())
|
||||
|
||||
private def resolveServer(tournamentId: String): Option[String] =
|
||||
tournamentService.get(tournamentId)
|
||||
tournamentService
|
||||
.get(tournamentId)
|
||||
.flatMap(t => Option(t.originServerUrl))
|
||||
.orElse(registry.findServerUrl(tournamentId))
|
||||
.orElse {
|
||||
|
||||
+64
-14
@@ -6,6 +6,7 @@ import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.util.Try
|
||||
|
||||
@@ -13,30 +14,58 @@ import scala.util.Try
|
||||
class ExternalTournamentClient:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@volatile private var directorToken: Option[String] = None
|
||||
|
||||
@ConfigProperty(name = "nowchess.tournament.native-server-url", defaultValue = "http://141.37.123.132:8086")
|
||||
var nativeServerUrl: String = uninitialized
|
||||
|
||||
@ConfigProperty(name = "nowchess.tournament.director-name", defaultValue = "NowChess System")
|
||||
var directorName: String = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
private def buildClient(): Client = ClientBuilder.newClient()
|
||||
|
||||
def publishNative(serverUrl: String, directorName: String, form: String): Boolean =
|
||||
// The tournament-server only accepts HS256 tokens it issued. Never forward a NowChessSystems
|
||||
// RS256 user token to it — swap in the director token registered on that server.
|
||||
private def normalize(url: String): String = url.stripSuffix("/")
|
||||
|
||||
def isNativeServer(serverUrl: String): Boolean =
|
||||
nativeServerUrl.nonEmpty && normalize(serverUrl) == normalize(nativeServerUrl)
|
||||
|
||||
private def directorBearer(): Option[String] =
|
||||
directorToken
|
||||
.orElse {
|
||||
val fresh = registerDirector(nativeServerUrl, directorName)
|
||||
directorToken = fresh
|
||||
fresh
|
||||
}
|
||||
.map(t => s"Bearer $t")
|
||||
|
||||
private def authFor(serverUrl: String, userAuth: Option[String]): Option[String] =
|
||||
if isNativeServer(serverUrl) then directorBearer() else userAuth
|
||||
|
||||
def publishNative(serverUrl: String, directorName: String, form: String): Option[String] =
|
||||
val token = directorToken.orElse {
|
||||
val fresh = registerDirector(serverUrl, directorName)
|
||||
directorToken = fresh
|
||||
fresh
|
||||
}
|
||||
token.exists { tok =>
|
||||
if createNative(serverUrl, tok, form) then true
|
||||
else
|
||||
token.flatMap { tok =>
|
||||
createNative(serverUrl, tok, form).orElse {
|
||||
val refreshed = registerDirector(serverUrl, directorName)
|
||||
directorToken = refreshed
|
||||
refreshed.exists(createNative(serverUrl, _, form))
|
||||
refreshed.flatMap(createNative(serverUrl, _, form))
|
||||
}
|
||||
}
|
||||
|
||||
private def registerDirector(serverUrl: String, name: String): Option[String] =
|
||||
registerAccount(serverUrl, name, isBot = false)
|
||||
|
||||
private def registerAccount(serverUrl: String, name: String, isBot: Boolean): Option[String] =
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":false}"""
|
||||
val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":$isBot}"""
|
||||
val response = client
|
||||
.target(s"$serverUrl/api/auth/register")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
@@ -50,7 +79,25 @@ class ExternalTournamentClient:
|
||||
client.close()
|
||||
}.getOrElse(None)
|
||||
|
||||
private def createNative(serverUrl: String, token: String, form: String): Boolean =
|
||||
// The tournament server holds only the director token, which cannot join as a bot. Register the
|
||||
// bot on the native server by name to mint a bot token, then join the native twin as that bot.
|
||||
def joinNativeAsBot(serverUrl: String, tournamentId: String, botName: String): Boolean =
|
||||
registerAccount(serverUrl, botName, isBot = true).exists { token =>
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val response = client
|
||||
.target(s"$serverUrl/api/tournament/$tournamentId/join")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", s"Bearer $token")
|
||||
.post(Entity.json(""))
|
||||
try response.getStatus / 100 == 2 || response.getStatus == 409
|
||||
finally
|
||||
response.close()
|
||||
client.close()
|
||||
}.getOrElse(false)
|
||||
}
|
||||
|
||||
private def createNative(serverUrl: String, token: String, form: String): Option[String] =
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val response = client
|
||||
@@ -58,11 +105,14 @@ class ExternalTournamentClient:
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", s"Bearer $token")
|
||||
.post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED))
|
||||
try response.getStatus / 100 == 2
|
||||
try
|
||||
if response.getStatus / 100 == 2 then
|
||||
Option(objectMapper.readTree(response.readEntity(classOf[String])).path("id").asText()).filter(_.nonEmpty)
|
||||
else None
|
||||
finally
|
||||
response.close()
|
||||
client.close()
|
||||
}.getOrElse(false)
|
||||
}.getOrElse(None)
|
||||
|
||||
def fetchList(serverUrl: String): Option[JsonNode] =
|
||||
Try {
|
||||
@@ -105,7 +155,7 @@ class ExternalTournamentClient:
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val builder = client.target(s"$serverUrl/$path").request(MediaType.APPLICATION_JSON)
|
||||
val withAuth = authHeader.fold(builder)(h => builder.header("Authorization", h))
|
||||
val withAuth = authFor(serverUrl, authHeader).fold(builder)(h => builder.header("Authorization", h))
|
||||
val response = withAuth.post(Entity.json(""))
|
||||
try (response.getStatus, response.readEntity(classOf[String]))
|
||||
finally
|
||||
@@ -115,8 +165,8 @@ class ExternalTournamentClient:
|
||||
|
||||
def replicateTournament(serverUrl: String, req: ReplicateTournamentRequest, selfUrl: String): Boolean =
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val body = objectMapper.writeValueAsString(req)
|
||||
val client = buildClient()
|
||||
val body = objectMapper.writeValueAsString(req)
|
||||
val response = client
|
||||
.target(s"$serverUrl/api/tournament/replicate")
|
||||
.request(MediaType.APPLICATION_JSON)
|
||||
@@ -132,7 +182,7 @@ class ExternalTournamentClient:
|
||||
Try {
|
||||
val client = buildClient()
|
||||
val builder = client.target(s"$serverUrl/$path").request("application/x-ndjson")
|
||||
val withAuth = authHeader.fold(builder)(h => builder.header("Authorization", h))
|
||||
val withAuth = authFor(serverUrl, authHeader).fold(builder)(h => builder.header("Authorization", h))
|
||||
val response = withAuth.get()
|
||||
if response.getStatus == 200 then Some(response.readEntity(classOf[java.io.InputStream]))
|
||||
else
|
||||
|
||||
+8
@@ -82,6 +82,14 @@ class TournamentService:
|
||||
def get(id: String): Option[Tournament] =
|
||||
tournamentRepository.findOptById(id)
|
||||
|
||||
@Transactional
|
||||
def setNativeTournamentId(id: String, nativeId: String): Unit =
|
||||
tournamentRepository.findOptById(id).foreach(_.nativeTournamentId = nativeId)
|
||||
|
||||
@Transactional
|
||||
def markStatus(id: String, status: String): Unit =
|
||||
tournamentRepository.findOptById(id).foreach(_.status = status)
|
||||
|
||||
def list(): (List[Tournament], List[Tournament], List[Tournament]) =
|
||||
(
|
||||
tournamentRepository.findByStatus("created"),
|
||||
|
||||
@@ -30,3 +30,7 @@ nowchess:
|
||||
secret: test-secret
|
||||
auth:
|
||||
enabled: false
|
||||
tournament:
|
||||
self-url: ""
|
||||
external-servers: ""
|
||||
native-server-url: "http://localhost:1"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=6
|
||||
MINOR=9
|
||||
PATCH=0
|
||||
|
||||
Reference in New Issue
Block a user