Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
691 changes: 553 additions & 138 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ bpci = "0.1.0-beta.7"
rand_distr = "0.4.3"
color-print = "0.3.5"
ctrlc = "3.4.2"
# TODO: Consider putting these dependencies behind a feature flag
# since they are only used by the visualizer.
open = "5.3.2"
tungstenite = "0.27.0"
json = "0.12.4"
6 changes: 6 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub struct CliOptions {
pub komi: Komi,
pub tournament_type: TournamentType,
pub sprt: Option<SprtParameters>,
pub visualize: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -155,6 +156,10 @@ pub fn parse_cli_arguments_from(
.value_name("options")
.num_args(0..)
.action(ArgAction::Append))
.arg(Arg::new("visualize")
.long("visualize")
.help("Visualize the match using the PTN-Ninja API")
.action(ArgAction::SetTrue))
.try_get_matches_from(itr)?;

let engines: Vec<CliEngine> = matches
Expand Down Expand Up @@ -432,5 +437,6 @@ pub fn parse_cli_arguments_from(
komi: *matches.get_one::<Komi>("komi").unwrap(),
tournament_type,
sprt,
visualize: matches.get_flag("visualize"),
})
}
53 changes: 47 additions & 6 deletions src/game.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ use crate::openings::Opening;
use crate::tournament::{EngineId, Worker};
use crate::uci::parser::parse_info_string;
use crate::uci::UciInfo;
use crate::visualize;
use board_game_traits::Color;
use chrono::{Datelike, Local};
use log::{error, warn};
use pgn_traits::PgnPosition;
use std::fmt::Write;
use std::sync::mpsc;
use std::time::Instant;
use std::{io, thread};
use tiltak::position::Komi;
Expand All @@ -34,12 +36,33 @@ impl<B: PgnPosition + Clone> ScheduledGame<B> {
self,
worker: &mut Worker,
position_settings: &B::Settings,
tx: Option<std::sync::Arc<mpsc::Sender<mpsc::Receiver<visualize::Message<B>>>>>, // FIXME: Long type
) -> io::Result<Game<B>> {
let visualize = tx.is_some();
let (move_tx, move_rx) = mpsc::channel();
if let Some(tx) = tx.as_ref() {
// HACK: Relies on the file being there
open::that("visualizer.html")
.expect("`visualizer.html` should be in the working directory.");
tx.send(move_rx).expect(
"The WebSocket server thread should still be alive to receive the move receiver.",
);
}

let mut position =
B::from_fen_with_settings(&self.opening.root_position.to_fen(), position_settings)
.unwrap();
let white = self.white_engine_id.0;
let black = self.black_engine_id.0;
if visualize {
move_tx
.send(visualize::Message::Start {
white: worker.engines[white].name().to_string(),
black: worker.engines[black].name().to_string(),
root_position: position.clone(),
})
.expect("Sub-thread should be alive.");
}

let mut moves: Vec<PtnMove<B::Move>> = self
.opening
Expand All @@ -54,6 +77,14 @@ impl<B: PgnPosition + Clone> ScheduledGame<B> {

for PtnMove { mv, .. } in moves.iter() {
position.do_move(mv.clone());
if visualize {
move_tx
.send(visualize::Message::Ply {
mv: mv.clone(),
eval: None,
})
.expect("Sub-thread should be alive.");
}
}

worker.engines[white].uci_write_line(&format!("teinewgame {}", self.size))?;
Expand Down Expand Up @@ -165,19 +196,29 @@ impl<B: PgnPosition + Clone> ScheduledGame<B> {
}
position.do_move(mv.clone());

let score_string = match last_uci_info {
let side_to_move_flipper = match position.side_to_move() {
Color::White => -1,
Color::Black => 1,
};
let score_string = match &last_uci_info {
Some(uci_info) => format!(
"{:+.2}/{} {:.2}s",
match position.side_to_move() {
// Flip sign if last move was black's
Color::White => uci_info.cp_score as f64 / -100.0,
Color::Black => uci_info.cp_score as f64 / 100.0,
},
(side_to_move_flipper * uci_info.cp_score) as f64 / 100.0,
uci_info.depth,
time_taken.as_secs_f32(),
),
None => String::new(),
};
if visualize {
move_tx
.send(visualize::Message::Ply {
mv: mv.clone(),
eval: last_uci_info
.map(|uci_info| uci_info.cp_score * side_to_move_flipper),
})
.expect("Sub-thread should be alive.");
}

moves.push(PtnMove {
mv,
annotations: vec![],
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mod sprt;
mod tests;
mod tournament;
pub mod uci;
mod visualize;

fn main() -> Result<()> {
let cli_args = cli::parse_cli_arguments();
Expand Down Expand Up @@ -146,6 +147,7 @@ fn run_match<const S: usize>(
pgn_writer: Mutex::new(pgnout),
tournament_type: cli_args.tournament_type,
sprt: cli_args.sprt,
visualize: cli_args.visualize,
};

let tournament = Tournament::new(settings);
Expand Down
24 changes: 19 additions & 5 deletions src/tournament.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::openings::Opening;
use crate::pgn_writer::PgnWriter;
use crate::simulation::MatchScore;
use crate::sprt::{PentanomialResult, SprtParameters};
use crate::{exit_with_error, simulation};
use crate::{exit_with_error, simulation, visualize};
use board_game_traits::GameResult::*;
use pgn_traits::PgnPosition;
use std::num::NonZeroUsize;
Expand Down Expand Up @@ -53,6 +53,7 @@ pub struct TournamentSettings<B: PgnPosition> {
pub pgn_writer: Mutex<PgnWriter<B>>,
pub tournament_type: TournamentType,
pub sprt: Option<SprtParameters>,
pub visualize: bool,
}

impl<B: PgnPosition> fmt::Debug for TournamentSettings<B> {
Expand Down Expand Up @@ -146,11 +147,12 @@ pub struct Tournament<B: PgnPosition> {
pgn_writer: Mutex<PgnWriter<B>>,
tournament_type: TournamentType,
sprt: Option<SprtParameters>,
visualize: bool,
}

impl<B> Tournament<B>
where
B: PgnPosition + Clone + Send + 'static,
B: PgnPosition + Clone + Send + 'static + visualize::Visualize,
B::Move: Send,
B::Settings: Send + Sync,
{
Expand All @@ -167,6 +169,7 @@ where
pgn_writer: settings.pgn_writer,
tournament_type: settings.tournament_type,
sprt: settings.sprt,
visualize: settings.visualize,
}
}

Expand Down Expand Up @@ -222,6 +225,12 @@ where
})
.collect();

// Channel for sending per-game move `Receiver`s to the WebSocket server thread.
let (tx, rx) = std::sync::mpsc::channel();
if self.visualize {
B::run_websocket_server(rx);
}
let visualize_tx = Arc::new(tx);
let tournament_arc = Arc::new(self);

println!(
Expand All @@ -239,6 +248,7 @@ where
.into_iter()
.map(|mut worker| {
let thread_tournament = tournament_arc.clone();
let thread_visualize_tx = visualize_tx.clone();
let engine_names = engine_names.clone();
Builder::new()
.name(format!("#{}", worker.id)) // Note: The threads' names are used for logging
Expand All @@ -248,9 +258,13 @@ where
break;
}
let round_number = scheduled_game.round_number;
let game = match scheduled_game
.play_game(&mut worker, &thread_tournament.position_settings)
{
let game = match scheduled_game.play_game(
&mut worker,
&thread_tournament.position_settings,
thread_tournament
.visualize
.then_some(thread_visualize_tx.clone()),
) {
Ok(game) => game,
// If an error occurs that wasn't handled in play_game(), soft-abort the match
// and write a dummy game to the pgn output, so that later games won't be held up
Expand Down
88 changes: 88 additions & 0 deletions src/visualize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use std::sync::mpsc::Receiver;
use std::thread::Builder;

use pgn_traits::PgnPosition;
use tiltak::position::Position;

// The value must be the same as in `visualizer.html`.
const PORT: u16 = 30564;

pub enum Message<P: PgnPosition> {
Start {
white: String,
black: String,
root_position: P,
},
Ply {
mv: P::Move,
eval: Option<i64>,
},
}

// HACK: Workaround to get access to komi.
pub trait Visualize: PgnPosition {
fn run_websocket_server(rx: Receiver<Receiver<Message<Self>>>);
}

impl<const S: usize> Visualize for Position<S> {
fn run_websocket_server(rx: Receiver<Receiver<Message<Position<S>>>>) {
Builder::new()
.name("WebSocket Server".to_string())
.spawn(move || {
let server = std::net::TcpListener::bind(format!("127.0.0.1:{PORT}"))
.expect("The port should be available for creating a TCP listener.");
// Every time the visualizer window is opened, we get a new connection.
for (i, stream) in server.incoming().enumerate() {
// Get the move receiver for that game.
let move_rx = rx.recv().expect("The game thread should send the move receiver soon after opening the window.");
Builder::new()
.name(format!("WebSocket Move Relay #{i}"))
.spawn(move || -> Result<(), tungstenite::Error> {
let mut websocket = tungstenite::accept(stream.unwrap())
.expect("The incoming connection should be using `ws` instead of `wss`.");

// Initialize the game from the first message.
let (white, black, tps, komi) = match move_rx.recv() {
Ok(Message::Start {
white,
black,
root_position,
}) => (white, black, root_position.to_fen(), root_position.komi()),
Ok(_) => panic!("The first message sent should be a Start message."),
Err(_) => panic!("The game thread should still be alive.")
};
// TODO: Get Komi somehow
websocket
.send(tungstenite::Message::Text(
json::object! {
action: "SET_CURRENT_PTN",
value: format!("[Player1 \"{white}\"][Player2 \"{black}\"][TPS \"{tps}\"][Komi \"{komi}\"]"),
}.to_string().into()
))?;

// Forward moves from the game to the browser.
while let Ok(Message::Ply{ mv, eval }) = move_rx.recv() {
websocket.send(tungstenite::Message::Text(
json::object! {
action: "INSERT_PLY",
value: mv.to_string(),
}.to_string().into()
))?;
if let Some(cp) = eval {
websocket.send(tungstenite::Message::Text(
json::object! {
action: "SET_EVAL",
value: cp.clamp(-100, 100),
}.to_string().into()
))?
}
}

Ok(())
})
.unwrap();
}
})
.unwrap();
}
}
55 changes: 55 additions & 0 deletions visualizer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Racetrack Visualizer</title>
<style>
* {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
border: 0;
overflow: hidden;
}
</style>
</head>

<body>
<iframe
src="https://ptn.ninja/&evalText=false&showControls=false&stackCounts=true&disableBoard=true&disableNavigation=true&disableText=true&theme=CYSwzgxg9gTsQ"
allow="fullscreen" id="ninja">
</iframe>

<script>
const PORT = 30564;

const ninja = document.getElementById("ninja");
let ptnNinjaHasLoaded = false;

window.onmessage = function (event) {
if (event.source != ninja.contentWindow) return;
if (!ptnNinjaHasLoaded && event.data.action == "GAME_STATE") {
ptnNinjaHasLoaded = true;
const ws = new WebSocket(`ws://localhost:${PORT}`);

ws.onmessage = function (event) {
console.log(`[message] Received message: ${event.data}`);
try {
ninja.contentWindow.postMessage(JSON.parse(event.data), "*");
} catch (error) {
console.error(error);
}
};
ws.onerror = function (error) {
console.error(`[error] ${error}`);
};
}
console.log(JSON.stringify(event.data, null, 2));
}
</script>
</body>

</html>