From: Steinar H. Gunderson Date: Wed, 7 Feb 2024 16:47:21 +0000 (+0100) Subject: Handle streaming PGNs, like from Lichess (although this might break non-streaming... X-Git-Url: https://git.sesse.net/?p=remoteglot;a=commitdiff_plain;h=HEAD;hp=04509c20d2edea71df6e9c440d3260458e3cc34a Handle streaming PGNs, like from Lichess (although this might break non-streaming ones). --- diff --git a/.gitignore b/.gitignore index c6b42d3..3134bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ config.local.pm junk/ www/analysis.json www/js/remoteglot.min.js +www/css/remoteglot.min.css tblog.txt ucilog.txt ficslog.txt openings.txt www/history/ closure/ +index.html diff --git a/Position.pm b/Position.pm index 3e39e46..9e9fe29 100644 --- a/Position.pm +++ b/Position.pm @@ -191,6 +191,8 @@ sub fen { sub to_json_hash { my $pos = shift; my $json = { %$pos, fen => $pos->fen() }; + delete $json->{'toplay'}; + delete $json->{'move_num'}; delete $json->{'board'}; delete $json->{'prettyprint_cache'}; delete $json->{'tbprobe_cache'}; diff --git a/build.sh b/build.sh index 9f97e35..75141e4 100755 --- a/build.sh +++ b/build.sh @@ -1,21 +1,19 @@ #! /bin/sh -# Download http://dl.google.com/closure-compiler/compiler-latest.zip -# and unzip it in closure/ before running this script. +# Download the latest .jar from +# https://mvnrepository.com/artifact/com.google.javascript/closure-compiler/v20231112 +# (adjust the URL for the version you want) before running this script. -# The JQuery build comes from http://projects.jga.me/jquery-builder/, -# more specifically -# -# https://raw.githubusercontent.com/jgallen23/jquery-builder/0.7.0/dist/2.1.1/jquery-deprecated-sizzle.js +umask 022 -java -jar closure/compiler.jar \ - --language_in ECMASCRIPT5 \ +java -jar closure-compiler-v20231112.jar \ --compilation_level SIMPLE \ --js_output_file=www/js/remoteglot.min.js \ - www/js/jquery-deprecated-sizzle.js \ www/js/chessboard-0.3.0.js \ www/js/chess.js \ www/js/json_delta.js \ - www/js/jquery.sparkline.js \ www/js/remoteglot.js +cat www/css/chessboard-0.3.0.css www/css/remoteglot.css | sass --scss -t compressed > www/css/remoteglot.min.css +perl replace.pl < www/index.dev.html > www/index.html + diff --git a/clean-svg.pl b/clean-svg.pl new file mode 100644 index 0000000..e102edb --- /dev/null +++ b/clean-svg.pl @@ -0,0 +1,26 @@ +#! /usr/bin/perl +use strict; +use warnings; +use MIME::Base64; + +undef $/; +my $x = <>; +$x = MIME::Base64::decode_base64($x); +$x =~ s/>\s*#/>#g; # -> . +$x =~ s/;\s+/;/sg; # Remove whitespace in CSS. +$x =~ s/;"/"/g; # Remove final semicolon in CSS. +$x =~ s/\s+$//; # Remove tailing whitespace. +$x =~ s/>\n # Licensed under the GNU General Public License, version 2. @@ -14,7 +13,6 @@ use AnyEvent::Handle; use AnyEvent::HTTP; use Chess::PGN::Parse; use EV; -use Net::Telnet; use File::Slurp; use IPC::Open2; use Time::HiRes; @@ -35,7 +33,6 @@ my $output_timer = undef; my $http_timer = undef; my $stop_pgn_fetch = 0; my $tb_retry_timer = undef; -my %tb_cache = (); my $tb_lookup_running = 0; my $last_written_json = undef; @@ -48,12 +45,6 @@ $dbh->{RaiseError} = 1; $| = 1; -open(FICSLOG, ">ficslog.txt") - or die "ficslog.txt: $!"; -print FICSLOG "Log starting.\n"; -select(FICSLOG); -$| = 1; - open(UCILOG, ">ucilog.txt") or die "ucilog.txt: $!"; print UCILOG "Log starting.\n"; @@ -76,6 +67,15 @@ my $last_move; my $last_text = ''; my ($pos_calculating, $pos_calculating_second_engine); +# If not undef, we've started calculating this position but haven't ever given out +# any analysis for it, so we're on a forced timer to do so. +my $pos_calculating_started = undef; + +# If not undef, we've output this position, but without a main PV, so we're on +# _another_ forced timer to do so. +my $pos_pv_started = undef; +my $last_output_had_pv = 0; + setoptions($engine, \%remoteglotconf::engine_config); uciprint($engine, "ucinewgame"); @@ -87,46 +87,14 @@ if (defined($engine2)) { print "Chess engine ready.\n"; -# now talk to FICS -my ($t, $ev1); -if (defined($remoteglotconf::server)) { - $t = Net::Telnet->new(Timeout => 10, Prompt => '/fics% /'); - $t->input_log(\*FICSLOG); - $t->open($remoteglotconf::server); - $t->print($remoteglotconf::nick); - $t->waitfor('/Press return to enter the server/'); - $t->cmd(""); - - # set some options - $t->cmd("set shout 0"); - $t->cmd("set seek 0"); - $t->cmd("set style 12"); - - $ev1 = AnyEvent->io( - fh => fileno($t), - poll => 'r', - cb => sub { # what callback to execute - while (1) { - my $line = $t->getline(Timeout => 0, errmode => 'return'); - return if (!defined($line)); - - chomp $line; - $line =~ tr/\r//d; - handle_fics($line); - } - } - ); -} if (defined($remoteglotconf::target)) { if ($remoteglotconf::target =~ /^(?:\/|https?:)/) { - fetch_pgn($remoteglotconf::target); - } elsif (defined($t)) { - $t->cmd("observe $remoteglotconf::target"); + my $target = $remoteglotconf::target; + # Convenience. + $target =~ s#https://lichess.org/broadcast/.*/([^/]+)?$#https://lichess.org/api/stream/broadcast/round/$1.pgn#; + fetch_pgn($target); } } -if (defined($t)) { - print "FICS ready.\n"; -} # Engine events have already been set up by Engine.pm. EV::run; @@ -143,7 +111,7 @@ sub handle_uci { return if ($engine->{'stopping'} && $line !~ /^bestmove/); $engine->{'stopping'} = 0; - if ($line =~ /^info/) { + if ($line =~ /^info/ && $line !~ / cluster /) { my (@infos) = split / /, $line; shift @infos; @@ -163,82 +131,6 @@ my $pos_for_movelist = undef; my @uci_movelist = (); my @pretty_movelist = (); -sub handle_fics { - my $line = shift; - if ($line =~ /^<12> /) { - handle_position(Position->new($line)); - $t->cmd("moves"); - } - if ($line =~ /^Movelist for game /) { - my $pos = $pos_calculating; - if (defined($pos)) { - @uci_movelist = (); - @pretty_movelist = (); - $pos_for_movelist = Position->start_pos($pos->{'player_w'}, $pos->{'player_b'}); - $getting_movelist = 1; - } - } - if ($getting_movelist && - $line =~ /^\s* \d+\. \s+ # move number - (\S+) \s+ \( [\d:.]+ \) \s* # first move, then time - (?: (\S+) \s+ \( [\d:.]+ \) )? # second move, then time - /x) { - eval { - my $uci_move; - ($pos_for_movelist, $uci_move) = $pos_for_movelist->make_pretty_move($1); - push @uci_movelist, $uci_move; - push @pretty_movelist, $1; - - if (defined($2)) { - ($pos_for_movelist, $uci_move) = $pos_for_movelist->make_pretty_move($2); - push @uci_movelist, $uci_move; - push @pretty_movelist, $2; - } - }; - if ($@) { - warn "Error when getting FICS move history: $@"; - $getting_movelist = 0; - } - } - if ($getting_movelist && - $line =~ /^\s+ \{.*\} \s+ (?: \* | 1\/2-1\/2 | 0-1 | 1-0 )/x) { - # End of movelist. - if (defined($pos_calculating)) { - if ($pos_calculating->fen() eq $pos_for_movelist->fen()) { - $pos_calculating->{'history'} = \@pretty_movelist; - } - } - $getting_movelist = 0; - } - if ($line =~ /^([A-Za-z]+)(?:\([A-Z]+\))* tells you: (.*)$/) { - my ($who, $msg) = ($1, $2); - - next if (grep { $_ eq $who } (@remoteglotconf::masters) == 0); - - if ($msg =~ /^fics (.*?)$/) { - $t->cmd("tell $who Executing '$1' on FICS."); - $t->cmd($1); - } elsif ($msg =~ /^uci (.*?)$/) { - $t->cmd("tell $who Sending '$1' to the engine."); - print { $engine->{'write'} } "$1\n"; - } elsif ($msg =~ /^pgn (.*?)$/) { - my $url = $1; - $t->cmd("tell $who Starting to poll '$url'."); - fetch_pgn($url); - } elsif ($msg =~ /^stoppgn$/) { - $t->cmd("tell $who Stopping poll."); - $stop_pgn_fetch = 1; - $http_timer = undef; - } elsif ($msg =~ /^quit$/) { - $t->cmd("tell $who Bye bye."); - exit; - } else { - $t->cmd("tell $who Couldn't understand '$msg', sorry."); - } - } - #print "FICS: [$line]\n"; -} - # Starts periodic fetching of PGNs from the given URL. sub fetch_pgn { my ($url) = @_; @@ -253,14 +145,19 @@ sub fetch_pgn { }; if ($@) { warn "$url: $@"; - $http_timer = AnyEvent->timer(after => 1.0, cb => sub { + $http_timer = AnyEvent->timer(after => $remoteglotconf::poll_frequency, cb => sub { fetch_pgn($url); }); } } else { - AnyEvent::HTTP::http_get($url, sub { - handle_pgn(@_, $url); - }); + my $buffer = ''; + AnyEvent::HTTP::http_get($url, + on_body => sub { + handle_partial_pgn(@_, \$buffer, $url); + }, + sub { + end_pgn(@_, \$buffer, $url); + }); } } @@ -268,14 +165,32 @@ my ($last_pgn_white, $last_pgn_black); my @last_pgn_uci_moves = (); my $pgn_hysteresis_counter = 0; -sub handle_pgn { - my ($body, $header, $url) = @_; +sub handle_partial_pgn { + my ($body, $header, $buffer, $url) = @_; if ($stop_pgn_fetch) { $stop_pgn_fetch = 0; $http_timer = undef; - return; + return 0; } + $$buffer .= $body; + while ($$buffer =~ s/^\s*(.*)\n\n\n//s) { + handle_pgn($1, $url); + } + return 1; +} + +sub end_pgn { + my ($body, $header, $buffer, $url) = @_; + handle_pgn($$buffer, $url); + $$buffer = ""; + $http_timer = AnyEvent->timer(after => $remoteglotconf::poll_frequency, cb => sub { + fetch_pgn($url); + }); +} + +sub handle_pgn { + my ($body, $url) = @_; my $pgn = Chess::PGN::Parse->new(undef, $body); if (!defined($pgn)) { @@ -330,9 +245,12 @@ sub handle_pgn { if ($pgn->result eq '1-0' || $pgn->result eq '1/2-1/2' || $pgn->result eq '0-1') { $pos->{'result'} = $pgn->result; } - $pos->{'history'} = \@repretty_moves; + my @extra_moves = (); + $pos = extend_from_manual_override($pos, \@repretty_moves, \@extra_moves); extract_clock($pgn, $pos); + $pos->{'history'} = \@repretty_moves; + $pos->{'extra_moves'} = \@extra_moves; # Sometimes, PGNs lose a move or two for a short while, # or people push out new ones non-atomically. @@ -360,7 +278,7 @@ sub handle_pgn { } } - $http_timer = AnyEvent->timer(after => 1.0, cb => sub { + $http_timer = AnyEvent->timer(after => $remoteglotconf::poll_frequency, cb => sub { fetch_pgn($url); }); } @@ -376,6 +294,7 @@ sub handle_position { for my $key ('white_clock', 'black_clock', 'white_clock_target', 'black_clock_target') { $pos_calculating->{$key} //= $pos->{$key}; } + $pos_calculating->{'extra_moves'} = $pos->{'extra_moves'}; return; } @@ -388,12 +307,23 @@ sub handle_position { # the position.) # # Do not output anything new to the main analysis; that's - # going to be obsolete really soon. + # going to be obsolete really soon. (Exception: If we've never + # output anything for this move, ie., it didn't hit the 200ms + # limit, spit it out to the user anyway. It's probably a really + # fast blitz game or something, and it's good to show the moves + # as they come in even without great analysis.) $pos_calculating->{'white_clock'} = $pos->{'white_clock'}; $pos_calculating->{'black_clock'} = $pos->{'black_clock'}; delete $pos_calculating->{'white_clock_target'}; delete $pos_calculating->{'black_clock_target'}; - output_json(1); + + if (defined($pos_calculating_started)) { + output_json(0); + } else { + output_json(1); + } + $pos_calculating_started = [Time::HiRes::gettimeofday]; + $pos_pv_started = undef; # Ask the engine to stop; we will throw away its data until it # sends us "bestmove", signaling the end of it. @@ -411,6 +341,8 @@ sub handle_position { uciprint($engine, "position fen " . $pos->fen()); uciprint($engine, "go infinite"); $pos_calculating = $pos; + $pos_calculating_started = [Time::HiRes::gettimeofday]; + $pos_pv_started = undef; if (defined($engine2)) { if (defined($pos_calculating_second_engine)) { @@ -429,19 +361,6 @@ sub handle_position { $engine->{'info'} = {}; $last_move = time; - - schedule_tb_lookup(); - - # - # Output a command every move to note that we're - # still paying attention -- this is a good tradeoff, - # since if no move has happened in the last half - # hour, the analysis/relay has most likely stopped - # and we should stop hogging server resources. - # - if (defined($t)) { - $t->cmd("date"); - } } sub parse_infos { @@ -484,6 +403,7 @@ sub parse_infos { delete $info->{'score_cp' . $mpv}; delete $info->{'score_mate' . $mpv}; + delete $info->{'splicepos' . $mpv}; while ($x[0] eq 'cp' || $x[0] eq 'mate') { if ($x[0] eq 'cp') { @@ -579,8 +499,11 @@ sub complete_using_tbprobe { my @pv = @{$info->{'pv' . $mpv}}; my $key = $pos->fen() . " " . join('', @pv); my @moves = (); + my $splicepos; if (exists($tbprobe_cache{$key})) { - @moves = @{$tbprobe_cache{$key}}; + my $c = $tbprobe_cache{$key}; + @moves = @{$c->{'moves'}}; + $splicepos = $c->{'splicepos'}; } else { if ($mpv ne '') { # Force doing at least one move of the PV. @@ -598,13 +521,13 @@ sub complete_using_tbprobe { return if ($pos->num_pieces() > 7); my $fen = $pos->fen(); - my $pgn_text = `fathom --path=/srv/syzygy "$fen"`; + my $pgn_text = `$remoteglotconf::fathom_cmdline "$fen"`; my $pgn = Chess::PGN::Parse->new(undef, $pgn_text); return if (!defined($pgn) || !$pgn->read_game() || ($pgn->result ne '0-1' && $pgn->result ne '1-0')); $pgn->quick_parse_game; - $info->{'pv' . $mpv} = \@moves; # Splice the PV from the tablebase onto what we have so far. + $splicepos = scalar @moves; for my $move (@{$pgn->moves}) { last if $move eq '#'; last if $move eq '1-0'; @@ -615,7 +538,10 @@ sub complete_using_tbprobe { push @moves, $uci_move; } - $tbprobe_cache{$key} = \@moves; + $tbprobe_cache{$key} = { + moves => \@moves, + splicepos => $splicepos + }; } $info->{'pv' . $mpv} = \@moves; @@ -626,6 +552,7 @@ sub complete_using_tbprobe { } else { $info->{'score_mate' . $mpv} = $matelen; } + $info->{'splicepos' . $mpv} = $splicepos; } sub output { @@ -633,55 +560,32 @@ sub output { return if (!defined($pos_calculating)); - # Don't update too often. - my $age = Time::HiRes::tv_interval($latest_update); - if ($age < $remoteglotconf::update_max_interval) { - my $wait = $remoteglotconf::update_max_interval + 0.01 - $age; - $output_timer = AnyEvent->timer(after => $wait, cb => \&output); - return; - } - my $info = $engine->{'info'}; - # - # If we have tablebase data from a previous lookup, replace the - # engine data with the data from the tablebase. - # - my $fen = $pos_calculating->fen(); - if (exists($tb_cache{$fen})) { - for my $key (qw(pv score_cp score_mate nodes nps depth seldepth tbhits)) { - delete $info->{$key . '1'}; - delete $info->{$key}; - } - $info->{'nodes'} = 0; - $info->{'nps'} = 0; - $info->{'depth'} = 0; - $info->{'seldepth'} = 0; - $info->{'tbhits'} = 0; - - my $t = $tb_cache{$fen}; - my $pv = $t->{'pv'}; - my $matelen = int((1 + $t->{'score'}) / 2); - if ($t->{'result'} eq '1/2-1/2') { - $info->{'score_cp'} = 0; - } elsif ($t->{'result'} eq '1-0') { - if ($pos_calculating->{'toplay'} eq 'B') { - $info->{'score_mate'} = -$matelen; - } else { - $info->{'score_mate'} = $matelen; - } - } else { - if ($pos_calculating->{'toplay'} eq 'B') { - $info->{'score_mate'} = $matelen; - } else { - $info->{'score_mate'} = -$matelen; - } + # Don't update too often. + my $wait = $remoteglotconf::update_max_interval - Time::HiRes::tv_interval($latest_update); + if (defined($pos_calculating_started)) { + my $new_pos_wait = $remoteglotconf::update_force_after_move - Time::HiRes::tv_interval($pos_calculating_started); + $wait = $new_pos_wait if ($new_pos_wait < $wait); + } + if (!$last_output_had_pv && has_pv($info)) { + if (!defined($pos_pv_started)) { + $pos_pv_started = [Time::HiRes::gettimeofday]; } - $info->{'pv'} = $pv; - $info->{'tablebase'} = 1; - } else { - $info->{'tablebase'} = 0; + # We just got initial PV, and we're in a hurry since we gave out a blank one earlier, + # so give us just 200ms more to increase the quality and then force a display. + my $new_pos_wait = $remoteglotconf::update_force_after_move - Time::HiRes::tv_interval($pos_pv_started); + $wait = $new_pos_wait if ($new_pos_wait < $wait); + } + if ($wait > 0.0) { + $output_timer = AnyEvent->timer(after => $wait + 0.01, cb => \&output); + return; } + $pos_pv_started = undef; + + # We're outputting something for this position now, so the special handling + # for new positions is off. + undef $pos_calculating_started; # # Some programs _always_ report MultiPV, even with only one PV. @@ -689,7 +593,7 @@ sub output { # specified. # if (exists($info->{'pv1'}) && !exists($info->{'pv2'})) { - for my $key (qw(pv score_cp score_mate nodes nps depth seldepth tbhits)) { + for my $key (qw(pv score_cp score_mate nodes nps depth seldepth tbhits splicepos)) { if (exists($info->{$key . '1'})) { $info->{$key} = $info->{$key . '1'}; } else { @@ -734,6 +638,14 @@ sub output { output_screen(); output_json(0); $latest_update = [Time::HiRes::gettimeofday]; + $last_output_had_pv = has_pv($info); +} + +sub has_pv { + my $info = shift; + return 1 if (exists($info->{'pv'}) && (scalar(@{$info->{'pv'}}) > 0)); + return 1 if (exists($info->{'pv1'}) && (scalar(@{$info->{'pv1'}}) > 0)); + return 0; } sub output_screen { @@ -759,6 +671,11 @@ sub output_screen { } return unless (exists($pos_calculating->{'board'})); + + my $extra_moves = $pos_calculating->{'extra_moves'}; + if (defined($extra_moves) && scalar @$extra_moves > 0) { + $text .= " Manual move extensions: " . join(' ', @$extra_moves) . "\n"; + } if (exists($info->{'pv1'}) && exists($info->{'pv2'})) { # multi-PV @@ -811,8 +728,6 @@ sub output_screen { $text .= "\n\n"; } - #$text .= book_info($pos_calculating->fen(), $pos_calculating->{'board'}, $pos_calculating->{'toplay'}); - my @refutation_lines = (); if (defined($engine2)) { for (my $mpv = 1; $mpv < 500; ++$mpv) { @@ -881,7 +796,6 @@ sub output_json { $json->{'move_source_url'} = $remoteglotconf::move_source_url; } $json->{'score'} = score_digest($info, $pos_calculating, ''); - $json->{'using_lomonosov'} = defined($remoteglotconf::tb_serial_key); $json->{'nodes'} = $info->{'nodes'}; $json->{'nps'} = $info->{'nps'}; @@ -911,6 +825,9 @@ sub output_json { move => $pretty_move, pv => \@pretty_pv, }; + if (exists($info->{'splicepos' . $mpv})) { + $refutation_lines{$pretty_move}->{'splicepos'} = $info->{'splicepos' . $mpv}; + } }; } } @@ -1016,7 +933,7 @@ sub output_json { } if (exists($pos_calculating->{'history'}) && - defined($remoteglotconf::json_history_dir)) { + defined($remoteglotconf::json_history_dir) && defined($json->{'engine'}{name})) { my $id = id_for_pos($pos_calculating); my $filename = $remoteglotconf::json_history_dir . "/" . $id . ".json"; @@ -1122,7 +1039,24 @@ sub score_digest { if ($pos->{'toplay'} eq 'B') { $mate = -$mate; } - return ['m', $mate]; + if (exists($info->{'splicepos' . $mpv})) { + my $sp = $info->{'splicepos' . $mpv}; + if ($mate > 0) { + return ['T', $sp]; + } else { + return ['t', $sp]; + } + } else { + if ($mate > 0) { + return ['M', $mate]; + } elsif ($mate < 0) { + return ['m', -$mate]; + } elsif ($pos->{'toplay'} eq 'B') { + return ['M', 0]; + } else { + return ['m', 0]; + } + } } else { if (exists($info->{'score_cp' . $mpv})) { my $score = $info->{'score_cp' . $mpv}; @@ -1148,10 +1082,19 @@ sub long_score { if ($pos->{'toplay'} eq 'B') { $mate = -$mate; } - if ($mate > 0) { - return sprintf "White mates in %u", $mate; + if (exists($info->{'splicepos' . $mpv})) { + my $sp = $info->{'splicepos' . $mpv}; + if ($mate > 0) { + return sprintf "White wins in %u", int(($sp + 1) * 0.5); + } else { + return sprintf "Black wins in %u", int(($sp + 1) * 0.5); + } } else { - return sprintf "Black mates in %u", -$mate; + if ($mate > 0) { + return sprintf "White mates in %u", $mate; + } else { + return sprintf "Black mates in %u", -$mate; + } } } else { if (exists($info->{'score_cp' . $mpv})) { @@ -1203,56 +1146,32 @@ sub plot_score { return undef; } -my %book_cache = (); -sub book_info { - my ($fen, $board, $toplay) = @_; - - if (exists($book_cache{$fen})) { - return $book_cache{$fen}; - } - - my $ret = `./booklook $fen`; - return "" if ($ret =~ /Not found/ || $ret eq ''); - - my @moves = (); - - for my $m (split /\n/, $ret) { - my ($move, $annotation, $win, $draw, $lose, $rating, $rating_div) = split /,/, $m; +sub extend_from_manual_override { + my ($pos, $moves, $extra_moves) = @_; - my $pmove; - if ($move eq '') { - $pmove = '(current)'; - } else { - ($pmove) = prettyprint_pv_no_cache($board, $move); - $pmove .= $annotation; + my $q = $dbh->prepare('SELECT next_move FROM game_extensions WHERE fen=? AND history=? AND player_w=? AND player_b=? AND (CURRENT_TIMESTAMP - ts) < INTERVAL \'1 hour\''); + while (1) { + my $player_w = $pos->{'player_w'}; + my $player_b = $pos->{'player_b'}; + if ($player_w =~ /^base64:(.*)$/) { + $player_w = MIME::Base64::decode_base64($1); } - - my $score; - if ($toplay eq 'W') { - $score = 1.0 * $win + 0.5 * $draw + 0.0 * $lose; - } else { - $score = 0.0 * $win + 0.5 * $draw + 1.0 * $lose; + if ($player_b =~ /^base64:(.*)$/) { + $player_b = MIME::Base64::decode_base64($1); } - my $n = $win + $draw + $lose; - - my $percent; - if ($n == 0) { - $percent = " "; + #use Data::Dumper; print Dumper([$pos->fen(), JSON::XS::encode_json($moves), $player_w, $player_b]); + $q->execute($pos->fen(), JSON::XS::encode_json($moves), $player_w, $player_b); + my $ref = $q->fetchrow_hashref; + if (defined($ref)) { + my $move = $ref->{'next_move'}; + ($pos) = $pos->make_pretty_move($move); + push @$moves, $move; + push @$extra_moves, $move; } else { - $percent = sprintf "%4u%%", int(100.0 * $score / $n + 0.5); + last; } - - push @moves, [ $pmove, $n, $percent, $rating ]; } - - @moves[1..$#moves] = sort { $b->[2] cmp $a->[2] } @moves[1..$#moves]; - - my $text = "Book moves:\n\n Perf. N Rating\n\n"; - for my $m (@moves) { - $text .= sprintf " %-10s %s %6u %4s\n", $m->[0], $m->[2], $m->[1], $m->[3] - } - - return $text; + return $pos; } sub extract_clock { @@ -1316,7 +1235,6 @@ sub find_clock_start { return; } - # TODO(sesse): Maybe we can get the number of moves somehow else for FICS games. # The history is needed for id_for_pos. if (!exists($pos->{'history'})) { return; @@ -1373,84 +1291,6 @@ sub find_clock_start { $dbh->commit; } -sub schedule_tb_lookup { - return if (!defined($remoteglotconf::tb_serial_key)); - my $pos = $pos_calculating; - return if (exists($tb_cache{$pos->fen()})); - - # If there's more than seven pieces, there's not going to be an answer, - # so don't bother. - return if ($pos->num_pieces() > 7); - - # Max one at a time. If it's still relevant when it returns, - # schedule_tb_lookup() will be called again. - return if ($tb_lookup_running); - - $tb_lookup_running = 1; - my $url = 'http://tb7-api.chessok.com:6904/tasks/addtask?auth.login=' . - $remoteglotconf::tb_serial_key . - '&auth.password=aquarium&type=0&fen=' . - URI::Escape::uri_escape($pos->fen()); - print TBLOG "Downloading $url...\n"; - AnyEvent::HTTP::http_get($url, sub { - handle_tb_lookup_return(@_, $pos, $pos->fen()); - }); -} - -sub handle_tb_lookup_return { - my ($body, $header, $pos, $fen) = @_; - print TBLOG "Response for [$fen]:\n"; - print TBLOG $header . "\n\n"; - print TBLOG $body . "\n\n"; - eval { - my $response = JSON::XS::decode_json($body); - if ($response->{'ErrorCode'} != 0) { - die "Unknown tablebase server error: " . $response->{'ErrorDesc'}; - } - my $state = $response->{'Response'}{'StateString'}; - if ($state eq 'COMPLETE') { - my $pgn = Chess::PGN::Parse->new(undef, $response->{'Response'}{'Moves'}); - if (!defined($pgn) || !$pgn->read_game()) { - warn "Error in parsing PGN\n"; - } else { - $pgn->quick_parse_game; - my $pvpos = $pos; - my $moves = $pgn->moves; - my @uci_moves = (); - for my $move (@$moves) { - my $uci_move; - ($pvpos, $uci_move) = $pvpos->make_pretty_move($move); - push @uci_moves, $uci_move; - } - $tb_cache{$fen} = { - result => $pgn->result, - pv => \@uci_moves, - score => $response->{'Response'}{'Score'}, - }; - output(); - } - } elsif ($state =~ /QUEUED/ || $state =~ /PROCESSING/) { - # Try again in a second. Note that if we have changed - # position in the meantime, we might query a completely - # different position! But that's fine. - } else { - die "Unknown response state " . $state; - } - - # Wait a second before we schedule another one. - $tb_retry_timer = AnyEvent->timer(after => 1.0, cb => sub { - $tb_lookup_running = 0; - schedule_tb_lookup(); - }); - }; - if ($@) { - warn "Error in tablebase lookup: $@"; - - # Don't try this one again, but don't block new lookups either. - $tb_lookup_running = 0; - } -} - sub open_engine { my ($cmdline, $tag, $cb) = @_; return undef if (!defined($cmdline)); diff --git a/server/hash-lookup.js b/server/hash-lookup.js index 7d7daeb..e84974c 100644 --- a/server/hash-lookup.js +++ b/server/hash-lookup.js @@ -1,10 +1,133 @@ -var grpc = require('grpc'); -var Chess = require('../www/js/chess.js').Chess; +var grpc = require('@grpc/grpc-js'); var PROTO_PATH = __dirname + '/hashprobe.proto'; -var hashprobe_proto = grpc.load(PROTO_PATH).hashprobe; +var protoLoader = require('@grpc/proto-loader'); +var packageDefinition = protoLoader.loadSync( + PROTO_PATH, + {keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true + }); +var hashprobe_proto = grpc.loadPackageDefinition(packageDefinition).hashprobe; -var board = new Chess(); +/* + * validate_fen() is taken from chess.js, which has this license: + * + * Copyright (c) 2017, Jeff Hlywa (jhlywa@gmail.com) + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + *----------------------------------------------------------------------------*/ +function validate_fen(fen) { + var errors = { + 0: 'No errors.', + 1: 'FEN string must contain six space-delimited fields.', + 2: '6th field (move number) must be a positive integer.', + 3: '5th field (half move counter) must be a non-negative integer.', + 4: '4th field (en-passant square) is invalid.', + 5: '3rd field (castling availability) is invalid.', + 6: '2nd field (side to move) is invalid.', + 7: '1st field (piece positions) does not contain 8 \'/\'-delimited rows.', + 8: '1st field (piece positions) is invalid [consecutive numbers].', + 9: '1st field (piece positions) is invalid [invalid piece].', + 10: '1st field (piece positions) is invalid [row too large].', + 11: 'Illegal en-passant square', + }; + + /* 1st criterion: 6 space-seperated fields? */ + var tokens = fen.split(/\s+/); + if (tokens.length !== 6) { + return {valid: false, error_number: 1, error: errors[1]}; + } + + /* 2nd criterion: move number field is a integer value > 0? */ + if (isNaN(tokens[5]) || (parseInt(tokens[5], 10) <= 0)) { + return {valid: false, error_number: 2, error: errors[2]}; + } + + /* 3rd criterion: half move counter is an integer >= 0? */ + if (isNaN(tokens[4]) || (parseInt(tokens[4], 10) < 0)) { + return {valid: false, error_number: 3, error: errors[3]}; + } + + /* 4th criterion: 4th field is a valid e.p.-string? */ + if (!/^(-|[abcdefgh][36])$/.test(tokens[3])) { + return {valid: false, error_number: 4, error: errors[4]}; + } + + /* 5th criterion: 3th field is a valid castle-string? */ + if( !/^[C-HK]?[A-FQ]?[c-hk]?[a-fq]?$/.test(tokens[2]) && + tokens[2] !== '-') { + return {valid: false, error_number: 5, error: errors[5]}; + } + + /* 6th criterion: 2nd field is "w" (white) or "b" (black)? */ + if (!/^(w|b)$/.test(tokens[1])) { + return {valid: false, error_number: 6, error: errors[6]}; + } + + /* 7th criterion: 1st field contains 8 rows? */ + var rows = tokens[0].split('/'); + if (rows.length !== 8) { + return {valid: false, error_number: 7, error: errors[7]}; + } + + /* 8th criterion: every row is valid? */ + for (var i = 0; i < rows.length; i++) { + /* check for right sum of fields AND not two numbers in succession */ + var sum_fields = 0; + var previous_was_number = false; + + for (var k = 0; k < rows[i].length; k++) { + if (!isNaN(rows[i][k])) { + if (previous_was_number) { + return {valid: false, error_number: 8, error: errors[8]}; + } + sum_fields += parseInt(rows[i][k], 10); + previous_was_number = true; + } else { + if (!/^[prnbqkPRNBQK]$/.test(rows[i][k])) { + return {valid: false, error_number: 9, error: errors[9]}; + } + sum_fields += 1; + previous_was_number = false; + } + } + if (sum_fields !== 8) { + return {valid: false, error_number: 10, error: errors[10]}; + } + } + + if ((tokens[3][1] == '3' && tokens[1] == 'w') || + (tokens[3][1] == '6' && tokens[1] == 'b')) { + return {valid: false, error_number: 11, error: errors[11]}; + } + + /* everything's okay! */ + return {valid: true, error_number: 0, error: errors[0]}; +} var clients = []; var current_servers = []; @@ -32,7 +155,7 @@ var init = function(servers) { exports.init = init; var handle_request = function(fen, response) { - if (fen === undefined || fen === null || fen === '' || !board.validate_fen(fen).valid) { + if (fen === undefined || fen === null || fen === '' || !validate_fen(fen).valid) { response.writeHead(400, {}); response.end(); return; @@ -68,11 +191,11 @@ var handle_response = function(fen, response, probe_responses) { var probe_response = reconcile_responses(probe_responses); var lines = {}; - var root = translate_line(board, fen, probe_response['root']); + var root = translate_line(fen, probe_response['root']); for (var i = 0; i < probe_response['line'].length; ++i) { var line = probe_response['line'][i]; var pretty_move = line['move']['pretty']; - lines[pretty_move] = translate_line(board, fen, line); + lines[pretty_move] = translate_line(fen, line); } var text = JSON.stringify({ @@ -141,7 +264,7 @@ var reconcile_moves = function(a, b) { } } -var translate_line = function(board, fen, line) { +var translate_line = function(fen, line) { var r = {}; if (line['move'] && line['move']['pretty']) { diff --git a/server/serve-analysis.js b/server/serve-analysis.js index 86f2d90..8448cfa 100644 --- a/server/serve-analysis.js +++ b/server/serve-analysis.js @@ -23,9 +23,11 @@ var json_filename = '/srv/analysis.sesse.net/www/analysis.json'; if (process.argv.length >= 3) { json_filename = process.argv[2]; } +var html_filename = '/srv/analysis.sesse.net/www/index.html'; // Expected destination filenames. var serve_url = '/analysis.pl'; +var html_serve_url = '/index-inline.html'; var hash_serve_url = '/hash'; if (process.argv.length >= 4) { serve_url = process.argv[3]; @@ -53,6 +55,7 @@ var json_lock = 0; // The current contents of the file to hand out, and its last modified time. var json = undefined; +var html = undefined; // The last five timestamps, and diffs from them to the latest version. var historic_json = []; @@ -134,6 +137,17 @@ var create_json_historic_diff = function(new_json, history_left, new_diff_json, var histobj = history_left.shift(); var diff = delta.JSON_delta.diff(histobj.parsed, new_json.parsed); var diff_text = JSON.stringify(diff); + + // Verify that the delta is correct + var base = JSON.parse(histobj.plain); + delta.JSON_delta.patch(base, diff); + var correct_pv = JSON.stringify(base['pv']); + var wrong_pv = JSON.stringify(new_json.parsed['pv']); + if (correct_pv !== wrong_pv) { + console.log("Patch went wrong:", histobj.plain, new_json.plain); + exit(); + } + zlib.gzip(diff_text, function(err, buffer) { if (err) throw err; new_diff_json[histobj.last_modified] = { @@ -146,6 +160,23 @@ var create_json_historic_diff = function(new_json, history_left, new_diff_json, }); } +function read_entire_file(filename, callback) { + fs.open(filename, 'r', function(err, fd) { + if (err) throw err; + fs.fstat(fd, function(err, st) { + if (err) throw err; + var buffer = new Buffer(1048576); + fs.read(fd, buffer, 0, 1048576, 0, function(err, bytesRead, buffer) { + if (err) throw err; + fs.close(fd, function() { + var contents = buffer.toString('utf8', 0, bytesRead); + callback(contents, st.mtime.getTime()); + }); + }); + }); + }); +} + var reread_file = function(event, filename) { if (filename != path.basename(json_filename)) { return; @@ -162,17 +193,34 @@ var reread_file = function(event, filename) { json_lock = 1; console.log("Rereading " + json_filename); - fs.open(json_filename, 'r', function(err, fd) { - if (err) throw err; - fs.fstat(fd, function(err, st) { - if (err) throw err; - var buffer = new Buffer(1048576); - fs.read(fd, buffer, 0, 1048576, 0, function(err, bytesRead, buffer) { + read_entire_file(json_filename, function(new_json_contents, mtime) { + replace_json(new_json_contents, mtime); + + // The HTML can go async, it's not too hopeless if it's out of date by a few milliseconds + read_entire_file(html_filename, function(new_html_contents, html_mtime) { + var json_headers = { + 'X-RGLM': mtime, + 'X-RGNV': count_viewers(), // May be slightly out of date. + 'Date': (new Date).toUTCString(), + }; + if (MINIMUM_VERSION) { + json_headers['X-RGMV'] = MINIMUM_VERSION; + } + let inline_json = { + 'data': JSON.parse(new_json_contents), + 'headers': json_headers, + }; + delete inline_json['data']['internal']; + + new_html_contents = new_html_contents.replace( + '/*REPLACE:inlinejson*/', + 'window.inline_json=' + JSON.stringify(inline_json) + ';'); + zlib.gzip(new_html_contents, function(err, buffer) { if (err) throw err; - fs.close(fd, function() { - var new_json_contents = buffer.toString('utf8', 0, bytesRead); - replace_json(new_json_contents, st.mtime.getTime()); - }); + html = { + plain: new_html_contents, + gzip: buffer, + }; }); }); }); @@ -231,6 +279,24 @@ var send_json = function(response, ims, accept_gzip, num_viewers) { } response.end(); } +var send_html = function(response, accept_gzip, num_viewers) { + var headers = { + 'Content-type': 'text/html; charset=utf-8', + 'Vary': 'Accept-Encoding', + }; + + if (accept_gzip) { + headers['Content-Length'] = html.gzip.length; + headers['Content-Encoding'] = 'gzip'; + response.writeHead(200, headers); + response.write(html.gzip); + } else { + headers['Content-Length'] = html.plain.length; + response.writeHead(200, headers); + response.write(html.plain); + } + response.end(); +} var mark_recently_seen = function(unique) { if (unique) { last_seen_clients[unique] = (new Date).getTime(); @@ -277,7 +343,7 @@ if (COUNT_FROM_VARNISH_LOG) { // Note: We abuse serve_url as a regex. var varnishncsa = child_process.spawn( 'varnishncsa', ['-F', '%{%s}t %U %q tffb=%{Varnish:time_firstbyte}x', - '-q', 'ReqURL ~ "^' + serve_url + '"']); + '-q', 'ReqURL ~ "^(' + serve_url + '|' + html_serve_url + ')"']); var rl = readline.createInterface({ input: varnishncsa.stdout, output: varnishncsa.stdin, @@ -348,22 +414,22 @@ server.on('request', function(request, response) { hash_lookup.handle_request(fen, response); return; } - if (u.pathname !== serve_url) { + if (u.pathname !== serve_url && u.pathname !== html_serve_url) { // This is not the request you are looking for. send_404(response); return; } - mark_recently_seen(unique); - var accept_encoding = request.headers['accept-encoding']; - var accept_gzip; - if (accept_encoding !== undefined && accept_encoding.match(/\bgzip\b/)) { - accept_gzip = true; - } else { - accept_gzip = false; + let accept_gzip = (accept_encoding !== undefined && accept_encoding.match(/\bgzip\b/)); + + if (u.pathname === html_serve_url) { + send_html(response, accept_gzip, count_viewers()); + return; } + mark_recently_seen(unique); + // If we already have something newer than what the user has, // just send it out and be done with it. if (json !== undefined && (!ims || json.last_modified > ims)) { diff --git a/www/analysis.pl b/www/analysis.pl old mode 100755 new mode 100644 diff --git a/www/css/chessboard-0.3.0.css b/www/css/chessboard-0.3.0.css index e987b52..557f283 100644 --- a/www/css/chessboard-0.3.0.css +++ b/www/css/chessboard-0.3.0.css @@ -1,70 +1,67 @@ -/*! - * chessboard.js v0.3.0 - * - * Copyright 2013 Chris Oakman - * Released under the MIT license - * https://github.com/oakmac/chessboardjs/blob/master/LICENSE - * - * Date: 10 Aug 2013 - */ - -/* clearfix */ -.clearfix-7da63 { - clear: both; -} - -/* board */ -.board-b72b1 { - border: 2px solid #404040; - -moz-box-sizing: content-box; - box-sizing: content-box; -} - -/* square */ -.square-55d63 { - float: left; - position: relative; - - /* disable any native browser highlighting */ - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -/* white square */ -.white-1e1d7 { - background-color: #f0d9b5; - color: #b58863; -} - -/* black square */ -.black-3c85d { - background-color: #b58863; - color: #f0d9b5; -} - -/* highlighted square */ -.highlight1-32417, .highlight2-9c5d2 { - -webkit-box-shadow: inset 0 0 3px 3px yellow; - -moz-box-shadow: inset 0 0 3px 3px yellow; - box-shadow: inset 0 0 3px 3px yellow; -} - -/* notation */ -.notation-322f9 { - cursor: default; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 14px; - position: absolute; -} -.alpha-d2270 { - bottom: 1px; - right: 3px; -} -.numeric-fc462 { - top: 2px; - left: 2px; -} \ No newline at end of file +/* + * chessboard.js v0.3.0+asn + * + * Copyright 2013 Chris Oakman + * Portions copyright 2022 Steinar H. Gunderson + * Released under the MIT license + * https://github.com/oakmac/chessboardjs/blob/master/LICENSE + * + * Date: 10 Aug 2013 + */ + +/* board */ +.board-b72b1 { + outline: 2px solid #404040; + display: grid; + grid-template-rows: repeat(8, 12.5%); + grid-template-columns: repeat(8, 12.5%); + grid-gap: 0px; + width: 100%; + aspect-ratio: 1; +} + +/* square */ +.square-55d63 { + /* disable any native browser highlighting */ + user-select: none; +} + +/* white square */ +.white-1e1d7 { + background-color: #f0d9b5; + color: #b58863; +} + +/* black square */ +.black-3c85d { + background-color: #b58863; + color: #f0d9b5; +} + +/* highlighted square */ +.highlight1-32417, .highlight2-9c5d2 { + box-shadow: inset 0 0 3px 3px yellow; +} + +/* notation */ +.alpha-d2270, .numeric-fc462 { + cursor: default; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + position: absolute; +} +.alpha-d2270 { + bottom: 1px; + right: 3px; +} +.numeric-fc462 { + top: 2px; + left: 2px; +} + +.piece-417db { + transform: translate(0,0); /* Force a new stacking context for SVG pieces. */ + position: absolute; + width: 12.5%; + height: 12.5%; +} diff --git a/www/css/chessboard-0.3.0.min.css b/www/css/chessboard-0.3.0.min.css deleted file mode 100644 index 52781a9..0000000 --- a/www/css/chessboard-0.3.0.min.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! chessboard.js v0.3.0 | (c) 2013 Chris Oakman | MIT License chessboardjs.com/license */ -.clearfix-7da63{clear:both}.board-b72b1{border:2px solid #404040;-moz-box-sizing:content-box;box-sizing:content-box}.square-55d63{float:left;position:relative;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.white-1e1d7{background-color:#f0d9b5;color:#b58863}.black-3c85d{background-color:#b58863;color:#f0d9b5}.highlight1-32417,.highlight2-9c5d2{-webkit-box-shadow:inset 0 0 3px 3px yellow;-moz-box-shadow:inset 0 0 3px 3px yellow;box-shadow:inset 0 0 3px 3px yellow}.notation-322f9{cursor:default;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;position:absolute}.alpha-d2270{bottom:1px;right:3px}.numeric-fc462{top:2px;left:2px} \ No newline at end of file diff --git a/www/css/remoteglot.css b/www/css/remoteglot.css index 09b6b08..a2e221f 100644 --- a/www/css/remoteglot.css +++ b/www/css/remoteglot.css @@ -1,5 +1,8 @@ -body { +html { + color-scheme: light dark; font-family: sans-serif; + background: white; + color: black; } h1 { margin-top: 0em; @@ -11,42 +14,41 @@ h3 { #scorecontainer { font-size: x-large; margin-top: 0; + display: grid; + grid-template-columns: max-content 1fr; + gap: 12px; } #score { - float: left; + grid-column: 1; } #scoresparkcontainer { + grid-column: 2; overflow: hidden; + height: 0.85em; + margin-top: 0.15em; + width: 100%; } -#scorespark { - margin-left: 0.5em; - margin-right: 0.5em; +#sparklinehover { + position: absolute; + display: none; + background-color: rgba(0, 0, 0, 0.6); + color: white; + font-size: 10px; + white-space: nowrap; + padding: 5px; + border: 1px solid white; + z-index: 10000; } #pvcontainer { clear: left; margin-top: 1em; } -.window { - position: absolute; - width: 0px; - height: 0px; - opacity: 0.0; -} .c1 { opacity: 0.75; } -.l1arrow { - opacity: 1.0; -} .hidden { display: none; } -.vir path { - opacity: 0.0; -} -.vir path.l1arrow { - opacity: 1.0; -} #news { font-size: smaller; } @@ -96,13 +98,14 @@ p { display: block; width: 100%; padding: 0; + transform: translate(0,0); /* Make it a containing block. */ } #hiddenboard { display: none; } #bottompanel { display: block; - width: 100%; + width: calc(100% + 4px); font-size: smaller; margin-top: 0.5em; margin-bottom: 0; @@ -178,9 +181,6 @@ a.move:hover { #linenav { display: none; } -#lomonosov { - display: none; -} #games { font-size: smaller; @@ -195,3 +195,44 @@ a.move:hover { .game:last-of-type { border-right: none; } + +.pv, #pv, #history { /* Mute move colors a bit. */ + color: #555; +} + +.imbalance-inverted-piece { + display: none; + filter: invert(1); +} + +.imbalance-piece, .imbalance-inverted-piece { + width: 15px; + height: 15px; +} + +@media (prefers-color-scheme: dark) { + +:root { + background: black; + color: #eee; +} +.pv, #pv, #history { /* Mute move colors a bit. */ + color: #bbb; +} +#numviewers { + color: #bbb; +} +a.move, a.move:link { + color: #eee; +} +a:link { + color: rgb(128,128,238); +} +.imbalance-piece { + display: none; +} +.imbalance-inverted-piece { + display: initial; +} + +} diff --git a/www/favicon.ico b/www/favicon.ico index e4e22dd..0148840 100644 Binary files a/www/favicon.ico and b/www/favicon.ico differ diff --git a/www/index.html b/www/index.dev.html similarity index 71% rename from www/index.html rename to www/index.dev.html index b6d9e8b..a44ec13 100644 --- a/www/index.html +++ b/www/index.dev.html @@ -2,14 +2,23 @@ - analysis.sesse.net - - - + + + + + + + + + + + + + - +