├── LICENSE.md ├── README.md └── files ├── .editorconfig ├── .gitignore ├── misc ├── chess960.txt ├── escape_and_missing_quotes.pgn ├── nibbler_menu_translations_template.json ├── pathological.pgn ├── pgn.txt ├── scraps.js ├── state_logic.txt └── uci.txt ├── res ├── linux │ └── nibbler.desktop ├── nibbler.png └── nibbler.svg ├── scripts ├── builder.py └── install.sh └── src ├── main.js ├── modules ├── alert_main.js ├── background.js ├── config_io.js ├── custom_uci.js ├── debork_json.js ├── empty.js ├── engineconfig_io.js ├── images.js ├── messages.js ├── running_as_electron.js ├── stringify.js ├── translate.js └── translations.js ├── nibbler.css ├── nibbler.html ├── package-lock.json ├── package.json ├── pieces ├── B.png ├── K.png ├── N.png ├── P.png ├── Q.png ├── R.png ├── _B.png ├── _K.png ├── _N.png ├── _P.png ├── _Q.png └── _R.png └── renderer ├── 10_globals.js ├── 20_utils.js ├── 30_point.js ├── 31_sliders.js ├── 40_position.js ├── 41_fen.js ├── 42_perft.js ├── 43_chess960.js ├── 50_table.js ├── 51_node.js ├── 52_sorted_moves.js ├── 55_winrate_graph.js ├── 60_pgn_utils.js ├── 61_pgn_parse.js ├── 63_polyglot.js ├── 65_loaders.js ├── 71_tree_handler.js ├── 72_tree_draw.js ├── 75_looker.js ├── 80_info.js ├── 81_arrows.js ├── 82_infobox.js ├── 83_statusbox.js ├── 90_engine.js ├── 95_hub.js └── 99_start.js /README.md: -------------------------------------------------------------------------------- 1 | # Nibbler 2 | 3 | Nibbler is a real-time analysis GUI for [Leela Chess Zero](http://lczero.org/play/quickstart/) (Lc0), which runs Leela in the background and constantly displays opinions about the current position. You can also compel the engine to evaluate one or more specific moves. Nibbler is loosely inspired by [Lizzie](https://github.com/featurecat/lizzie) and [Sabaki](https://github.com/SabakiHQ/Sabaki). 4 | 5 | These days, Nibbler more-or-less works with traditional engines like [Stockfish](https://stockfishchess.org/), too. (Ensure `MultiPV` is `1`, `Threads` (CPU) is set, and `Hash` is set (more is better), for maximum strength.) 6 | 7 | For prebuilt binary releases, see the [Releases](https://github.com/rooklift/nibbler/releases) section. For help, the [Discord](https://discordapp.com/invite/pKujYxD) may be your best bet, or open an issue here. 8 | 9 | ![Screenshot](https://user-images.githubusercontent.com/16438795/270297798-a432ea17-3601-4143-bddb-97420a0d6e6c.png) 10 | 11 | ## Features 12 | 13 | * Display Leela's top choices graphically. 14 | * Winrate graph. 15 | * Optionally shows Leela statistics like N, P, Q, S, U, V, and WDL for each move. 16 | * UCI `searchmoves` functionality. 17 | * Automatic full-game analysis. 18 | * Play against Leela from any position. 19 | * Leela self-play from any position. 20 | * PGN loading via menu, clipboard, or drag-and-drop. 21 | * Supports PGN variations of arbitrary depth. 22 | * FEN loading. 23 | * Chess 960. 24 | 25 | ## Installation - Windows / Linux 26 | 27 | Some Windows and Linux standalone releases are uploaded to the [Releases](https://github.com/rooklift/nibbler/releases) section from time to time. 28 | 29 | *Alternatively*, it is possible to run Nibbler from source. This requires Electron, but has no other dependencies. If you have Electron installed (e.g. `npm install -g electron`) you can likely enter the `/src` directory, then do `electron .` to run it. Nibbler should be compatible with at least version 5 and above. 30 | 31 | You could also build a standalone app. See comments inside the Python script `builder.py` for info. 32 | 33 | ## Linux install script 34 | 35 | Linux users can make use of the following *one-liner* to install the latest version of Nibbler: 36 | 37 | ```bash 38 | curl -L https://raw.githubusercontent.com/rooklift/nibbler/master/files/scripts/install.sh | bash 39 | ``` 40 | 41 | ## Installation - Mac 42 | 43 | Mac builds have been made by [twoplan](https://github.com/twoplan/Nibbler-for-macOS) and [Jac-Zac](https://github.com/Jac-Zac/Nibbler_MacOS) - the latter is probably more up-to-date. 44 | 45 | ## Advanced engine options 46 | 47 | Most people won't need them, but all of Leela's engine options can be set in two ways: 48 | 49 | * Leela automatically loads options from a file called `lc0.config` at startup - see [here](https://lczero.org/play/configuration/flags/#config-file). 50 | * Nibbler will send UCI options specified in Nibbler's own `engines.json` file (which you can find via the Dev menu). 51 | 52 | ## Hints and tips 53 | 54 | An option to enable the UCI `searchmoves` feature is available in the Analysis menu. Once enabled, one or more moves can be specified as moves to focus on; Leela will ignore other moves. This is useful when you think Leela isn't giving a certain move enough attention. 55 | 56 | Leela forgets much of the evaluation if the position changes. To mitigate this, an option in the Analysis menu allows you to hover over a PV (on the right) and see it play out on the board, without changing the position we're actually analysing. You might prefer to halt Leela while doing this, so that the PVs don't change while you're looking at them. 57 | 58 | Leela running out of RAM can be a problem if searches go on too long. You might like to set a reasonable node limit (in the Engine menu), perhaps 10 million or so. 59 | 60 | ## Thanks 61 | 62 | Thanks to everyone in Discord and GitHub who's offered advice and suggestions; and thanks to all Lc0 devs and GPU-hours contributors! 63 | 64 | The pieces are from [Lichess](https://lichess.org/). 65 | 66 | Icon design by [ciriousjoker](https://github.com/ciriousjoker) based on [this](https://www.svgrepo.com/svg/155301/chess). 67 | -------------------------------------------------------------------------------- /files/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | indent_size = 4 4 | trim_trailing_whitespace = true 5 | -------------------------------------------------------------------------------- /files/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | scripts/dist 3 | scripts/electron_zipped 4 | scripts/update_my_installation.py 5 | -------------------------------------------------------------------------------- /files/misc/escape_and_missing_quotes.pgn: -------------------------------------------------------------------------------- 1 | [Event "Test \"quotes\" \\ and escaping and missing quotes in the FEN tag"] 2 | [Site "local"] 3 | [Date "2021.04.01"] 4 | [Round "1"] 5 | [White "Jimmy \"The Pawn\" Smith"] 6 | [Black "Lc0 \\ Stockfish"] 7 | [Result "*"] 8 | [FEN rnbqkb1r/ppp1pppp/5n2/3p4/3P1B2/5N2/PPP1PPPP/RN1QKB1R b KQkq - 3 3] 9 | [SetUp "1"] 10 | 11 | 3... c5 {EV: 47.0%, N: 63.14% of 63.4k} 4. e3 {EV: 53.3%, N: 95.14% of 66.9k} e6 12 | {EV: 47.4%, N: 67.90% of 115k} 5. Nbd2 {EV: 53.1%, N: 67.58% of 96.2k} Qb6 {EV: 13 | 47.8%, N: 77.32% of 198k} 6. Rb1 {EV: 52.7%, N: 90.85% of 166k} * 14 | -------------------------------------------------------------------------------- /files/misc/nibbler_menu_translations_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "LANGUAGE_NAME_FIXME": { // The language name itself, in its own language. 3 | "File": "TODO", // As in: "file menu" 4 | "About": "TODO", 5 | "New game": "TODO", 6 | "New 960 game": "TODO", // New game of Chess960 7 | "Open PGN...": "TODO", // Portable game notation file 8 | "Load FEN / PGN from clipboard": "TODO", // Examines clipboard for valid FEN or PGN data 9 | "Save this game...": "TODO", // Saves as PGN 10 | "Write PGN to clipboard": "TODO", 11 | "PGN saved statistics": "TODO", // Has submenu of which statistics to save to PGN variations... 12 | "EV": "TODO", // ... expected value 13 | "Centipawns": "TODO", // ... 14 | "N (%)": "TODO", // ... N (node count, percent of total) 15 | "N (absolute)": "TODO", // ... N (absolute count) 16 | "...out of total": "TODO", // ... display total N 17 | "Depth (A/B only)": "TODO", // ... search depth, only displayed if alphabeta-style engine 18 | "Cut": "TODO", 19 | "Copy": "TODO", 20 | "Paste": "TODO", 21 | "Quit": "TODO", 22 | "Tree": "TODO", // Game tree manipulation functions 23 | "Play engine choice": "TODO", // Adds engine's choice of move to game tree / goes to it if already present 24 | "1st": "TODO", // ... i.e. play engine's top choice 25 | "2nd": "TODO", // ... i.e. play engine's 2nd choice 26 | "3rd": "TODO", // ... i.e. play engine's 3rd choice 27 | "4th": "TODO", // ... i.e. play engine's 3rd choice 28 | "Root": "TODO", // Move to root node in game tree 29 | "End": "TODO", // Move to final node in (this line of) game tree 30 | "Backward": "TODO", // Backward one move in game tree 31 | "Forward": "TODO", // Forward one move in game tree 32 | "Previous sibling": "TODO", // Go to previous sibling in game tree (if extant) 33 | "Next sibling": "TODO", // Go to next sibling in game tree (if extant) 34 | "Return to main line": "TODO", 35 | "Promote line to main line": "TODO", 36 | "Promote line by 1 level": "TODO", 37 | "Delete node": "TODO", 38 | "Delete children": "TODO", // i.e. child nodes in game tree 39 | "Delete siblings": "TODO", 40 | "Delete ALL other lines": "TODO", 41 | "Show PGN games list": "TODO", // For when a PGN file has multiple games 42 | "Escape": "TODO", // Escape key function - "escape" out of this screen to main screen 43 | "Analysis": "TODO", 44 | "Go": "TODO", // Starts the engine running 45 | "Go and lock engine": "TODO", // Starts the engine running on current position, and does NOT change the engine position even if GUI position changes 46 | "Return to locked position": "TODO", // Returns to the position the engine is analysing, IF the "go and lock engine" item was used 47 | "Halt": "TODO", // Stops the engine 48 | "Auto-evaluate line": "TODO", // Makes engine analyse a whole line of the game tree 49 | "Auto-evaluate line, backwards": "TODO", // Makes engine analyse a whole line of the game tree, backwards 50 | "Show focus (searchmoves) buttons": "TODO", // Shows buttons next to each legal move to allow the engine to focus analysis on selected moves 51 | "Clear focus": "TODO", // Deselects all such buttons 52 | "Invert focus": "TODO", // Inverts all such buttons 53 | "Winrate POV": "TODO", // Winrate (really EV) displayed from whose point of view? 54 | "Current": "TODO", // ... displayed from current player's 55 | "White": "TODO", // ... displayed from White's point of view 56 | "Black": "TODO", // ... displayed from Black's point of view 57 | "Centipawn POV": "TODO", // Centipawn scores displayed from whose point of view? (submenu not in this list) 58 | "Win / draw / loss POV": "TODO", // WDL statistic displayed from whose point of view? (submenu not in this list) 59 | "PV clicks": "TODO", // What to do when user clicks on a displayed principal variation (PV) 60 | "Do nothing": "TODO", // ... nothing 61 | "Go there": "TODO", // ... move to that position 62 | "Add to tree": "TODO", // ... add PV to game tree 63 | "Write infobox to clipboard": "TODO", 64 | "Forget all analysis": "TODO", 65 | "Display": "TODO", // Has large submenu of display options 66 | "Flip board": "TODO", 67 | "Arrows": "TODO", 68 | "Piece-click spotlight": "TODO", // Displays arrows showing legal moves, when a piece is clicked 69 | "Always show actual move (if known)": "TODO", // Displays arrow representing move actually played in the game record (if known) 70 | "...with unique colour": "TODO", // ... displays that arrow with a unique colour 71 | "...with outline": "TODO", // ... and an outline 72 | "Arrowhead type": "TODO", // Arrows display what statistic? 73 | "Winrate": "TODO", // ... (really expected value) 74 | "Node %": "TODO", // ... What % of examined nodes (examined by the engine) were this move? 75 | "Policy": "TODO", // Policy / prior 76 | "MultiPV rank": "TODO", // Ordinal rank in engine's preferred moves 77 | "Moves Left Head": "TODO", // Output of engine neural network head estimating "moves left before game ends" 78 | "Arrow filter (Lc0)": "TODO", // Filter criteria for displaying / not displaying arrows (when using Lc0 engine) 79 | "All moves": "TODO", // ... always display all arrows 80 | "Top move": "TODO", // ... display arrow for top move only 81 | "Arrow filter (others)": "TODO", // Filter criteria for displaying / not displaying arrows (when using Stockfish or similar) 82 | "Diff < 15%": "TODO", // ... EV was within 15% of best move 83 | "Diff < 10%": "TODO", // ... EV was within 10% of best move 84 | "Diff < 5%": "TODO", // ... EV was within 5% of best move 85 | "Infobox stats": "TODO", // Stats to display in info pane 86 | "N - nodes (%)": "TODO", // ... 87 | "N - nodes (absolute)": "TODO", // ... 88 | "P - policy": "TODO", // ... 89 | "V - static evaluation": "TODO", // ... 90 | "Q - evaluation": "TODO", // ... 91 | "U - uncertainty": "TODO", // ... 92 | "S - search priority": "TODO", // ... 93 | "M - moves left": "TODO", // ... 94 | "WDL - win / draw / loss": "TODO", // ... 95 | "Linebreak before stats": "TODO", 96 | "PV move numbers": "TODO", // Display move numbers when writing principal variation e.g. 40. Kc2 Qf1 41. Rd8 etc etc 97 | "Online API": "TODO", // Access an online API to display real-world game outcome stats 98 | "None": "TODO", // ... Don't access API 99 | "ChessDB.cn evals": "TODO", // ... Use ChessDB.cn 100 | "Lichess results (masters)": "TODO", // ... Use results from Lichess master player database 101 | "Lichess results (plebs)": "TODO", // ... Use results from Lichess user database 102 | "Allow API after move 25": "TODO", 103 | "Draw PV on mouseover": "TODO", 104 | "Draw PV method": "TODO", // How to draw a PV when mouse-over-ing part of it 105 | "Animate": "TODO", // Animate a series of moves 106 | "Single move": "TODO", // Show exactly the position after the move being hovered over 107 | "Final position": "TODO", // Show the final position in the sequence 108 | "Pieces": "TODO", // Icons / images to use for piece display 109 | "Choose pieces folder...": "TODO", // ... Select folder containing such images 110 | "Default": "TODO", // ... Just use default images 111 | "About custom pieces": "TODO", // Displays text explaining how to create custom images 112 | "Background": "TODO", // Background to use for board display 113 | "Choose background image...": "TODO", // ... Select image file for background 114 | "Book frequency arrows": "TODO", // Dispay arrows representing move frequency in opening book file 115 | "Lichess frequency arrows": "TODO", // Dispay arrows representing move frequency in Lichess database 116 | "Sizes": "TODO", // Adjust sizes of displayed things 117 | "Infobox font": "TODO", // Adjust SIZE of font in info pane 118 | "Move history font": "TODO", // Adjust SIZE of font in game tree pane 119 | "Board": "TODO", // Adjust SIZE of board 120 | "Giant": "TODO", // ... 121 | "Large": "TODO", // ... 122 | "Medium": "TODO", // ... 123 | "Small": "TODO", // ... 124 | "Graph": "TODO", // Adjust SIZE (height) of graph displaying winrate 125 | "Graph lines": "TODO", // Adjust SIZE of graph line 126 | "I want other size options!": "TODO", // Displays text explaining how to set custom size options 127 | "Engine": "TODO", // Top level menu item for engine settings 128 | "Choose engine...": "TODO", // Choose engine via file picker 129 | "Choose known engine...": "TODO", // Choose engine from a list of previously used engines 130 | "Weights": "TODO", // Choose neural net weight file 131 | "Lc0 WeightsFile...": "TODO", // ... For Lc0 132 | "Stockfish EvalFile...": "TODO", // ... For Stockfish 133 | "Set to ": "TODO", // ... Just allow engine to use default 134 | "Backend": "TODO", // Backend choices for Lc0 engine (submenu not in this list) 135 | "Choose Syzygy path...": "TODO", // Find endgame tablebase folder 136 | "Unset": "TODO", // Clear endgame tablebase folder 137 | "Limit - normal": "TODO", // Node limit for engine in normal circumstances 138 | "Unlimited": "TODO", // ... no node limit (note: other options exist but are not in this list) 139 | "Up slightly": "TODO", // ... increase node limit slightly 140 | "Down slightly": "TODO", // ... decrease node limit slightly 141 | "Limit - auto-eval / play": "TODO", // Node limit for engine when playing or evaluating a game 142 | "Limit by time instead of nodes": "TODO", // Consider node limit setting as a time limit in milliseconds instead 143 | "Threads": "TODO", // How many threads engine should used 144 | "Warning about threads": "TODO", // ... Displays a text warning about when not to use too many threads 145 | "Hash": "TODO", // Hash size (submenu not in this list) 146 | "I want other hash options!": "TODO", // ... Displays text about how to have a custom hash setting 147 | "MultiPV": "TODO", // How many lines a Stockfish-like engine should consider at once (multipv setting) 148 | "Contempt Mode": "TODO", 149 | "White analysis": "TODO", // ... Contempt from White's point of view 150 | "Black analysis": "TODO", // ... Contempt from Black's point of view 151 | "Contempt": "TODO", // Numerical contempt value (submenu not in this list) 152 | "WDL Calibration Elo": "TODO", // Technical contempt setting 153 | "Use default WDL": "TODO", // ... Technical contempt setting 154 | "WDL Eval Objectivity": "TODO", // Technical contempt setting 155 | "Yes": "TODO", // ... 156 | "No": "TODO", // ... 157 | "Score Type": "TODO", // Technical contempt setting 158 | "Custom scripts": "TODO", 159 | "How to add scripts": "TODO", // ... Displays text explanation 160 | "Show scripts folder": "TODO", // ... Displays folder in filesystem 161 | "Restart engine": "TODO", 162 | "Soft engine reset": "TODO", // Tells engine to reset cache etc, without actually restarting 163 | "Play": "TODO", // Top level menu item for playing against the engine 164 | "Play this colour": "TODO", // Engine itself plays current colour from now on 165 | "Start self-play": "TODO", // Engine plays against itself 166 | "Use Polyglot book...": "TODO", 167 | "Use PGN book...": "TODO", 168 | "Unload book / abort load": "TODO", 169 | "Book depth limit": "TODO", // What depth to stop using the book 170 | "Temperature": "TODO", // Temperature for Lc0 neural network 171 | "Temp Decay Moves": "TODO", // Technical temperature-related setting (numerical options present, but not in this list) 172 | "Infinite": "TODO", // ... 173 | "About play modes": "TODO", // Displays text 174 | "Dev": "TODO", // Top level menu for Dev / technical settings 175 | "Toggle Developer Tools": "TODO", 176 | "Toggle Debug CSS": "TODO", // Shows red border around HTML elements 177 | "Permanently enable save": "TODO", // Allows save function to be used 178 | "Show config.json": "TODO", // Displays config file in filesystem 179 | "Show engines.json": "TODO", // Displays config file in filesystem 180 | "Reload engines.json (and restart engine)": "TODO", 181 | "Random move": "TODO", // Makes a random move from legal possibilities 182 | "Disable hardware acceleration for GUI": "TODO", 183 | "Spin rate": "TODO", // How frequently to update GUI 184 | "Frenetic": "TODO", // ... 185 | "Fast": "TODO", // ... 186 | "Normal": "TODO", // ... 187 | "Relaxed": "TODO", // ... 188 | "Lazy": "TODO", // ... 189 | "Show engine state": "TODO", // Shows debug info about engine 190 | "List sent options": "TODO", // Shows a list of all options ever sent to the engine 191 | "Show error log": "TODO", // Shows engine stderr 192 | "Hacks and kludges": "TODO", // Submenu with specific hacks 193 | "Allow arbitrary scripts": "TODO", // ... 194 | "Accept any file size": "TODO", // ... 195 | "Allow stopped analysis": "TODO", // ... 196 | "Never hide focus buttons": "TODO", // ... 197 | "Never grayout move info": "TODO", // ... 198 | "Use lowerbound / upperbound info": "TODO", // ... 199 | "Suppress ucinewgame": "TODO", // ... do not send ucinewgame token to engine 200 | "Log RAM state to console": "TODO", 201 | "Fire GC": "TODO", // Call JS garbage collector 202 | "Logging": "TODO", 203 | "Use logfile...": "TODO", // ... 204 | "Disable logging": "TODO", // ... 205 | "Clear log when opening": "TODO", // ... 206 | "Use unique logfile each time": "TODO", // ... 207 | "Log illegal moves": "TODO", // ... 208 | "Log positions": "TODO", // ... 209 | "Log info lines": "TODO", // ... 210 | "...including useless lines": "TODO", // ... 211 | "Language": "TODO", 212 | 213 | "RESTART_REQUIRED": "TODO" // Special item - translate from "The GUI must now be restarted." 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /files/misc/scraps.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function NewPGNFileLoader(filename, callback) { 4 | 5 | let loader = Object.create(null); 6 | loader.type = "pgn"; 7 | 8 | loader.callback = callback; 9 | loader.msg = "Loading PGN..."; 10 | loader.buf = null; 11 | loader.preparser = null; 12 | 13 | loader.shutdown = function() { 14 | this.callback = null; 15 | this.msg = ""; 16 | this.buf = null; 17 | if (this.preparser) { 18 | this.preparser.shutdown(); 19 | this.preparser = null; 20 | } 21 | }; 22 | 23 | loader.load = function(filename) { 24 | fs.readFile(filename, (err, data) => { 25 | if (err) { 26 | console.log(err); 27 | this.shutdown(); 28 | } else if (this.callback) { // We might already have aborted 29 | this.buf = data; 30 | this.continue(); 31 | } 32 | }); 33 | }; 34 | 35 | loader.continue = function() { 36 | 37 | if (!this.callback) { 38 | return; 39 | } 40 | 41 | if (!this.preparser) { 42 | this.preparser = NewPGNPreParser(this.buf, (games) => { 43 | if (this.callback) { 44 | let cb = this.callback; cb(games); 45 | } 46 | this.shutdown(); 47 | }); 48 | } 49 | 50 | this.msg = this.preparser.msg; 51 | setTimeout(() => {this.continue();}, 20); // Just to update these messages. 52 | }; 53 | 54 | setTimeout(() => {loader.load(filename);}, 0); 55 | return loader; 56 | } 57 | 58 | // ------------------------------------------------------------------------------------------------------------------------------ 59 | 60 | function NewPGNPreParser(buf, callback) { // Cannot fail unless aborted. 61 | 62 | let loader = Object.create(null); 63 | loader.type = "pgn"; 64 | 65 | loader.callback = callback; 66 | loader.msg = "Preparsing..."; 67 | loader.games = [new_pgn_record()]; 68 | loader.lines = null; 69 | loader.buf = buf; 70 | loader.splitter = null; 71 | 72 | loader.n = 0; 73 | 74 | loader.shutdown = function() { 75 | this.callback = null; 76 | this.msg = ""; 77 | this.games = null; 78 | this.lines = null; 79 | this.buf = null; 80 | if (this.splitter) { 81 | this.splitter.shutdown(); 82 | this.splitter = null; 83 | } 84 | }; 85 | 86 | loader.continue = function() { 87 | 88 | if (!this.callback) { 89 | return; 90 | } 91 | 92 | if (!this.splitter) { 93 | this.splitter = NewLineSplitter(this.buf, (lines) => { 94 | this.lines = lines; 95 | }); 96 | } 97 | 98 | if (!this.lines) { 99 | this.msg = this.splitter.msg; 100 | setTimeout(() => {this.continue();}, 20); 101 | return; 102 | } 103 | 104 | let continuetime = performance.now(); 105 | 106 | while (true) { 107 | 108 | if (this.n >= this.lines.length) { 109 | break; 110 | } 111 | 112 | let rawline = this.lines[this.n++]; 113 | 114 | if (rawline.length === 0) { 115 | continue; 116 | } 117 | 118 | if (rawline[0] === 37) { // Percent % sign is a special comment type. 119 | continue; 120 | } 121 | 122 | let tagline = ""; 123 | 124 | if (rawline[0] === 91) { 125 | let s = decoder.decode(rawline).trim(); 126 | if (s.endsWith(`"]`)) { 127 | tagline = s; 128 | } 129 | } 130 | 131 | if (tagline !== "") { 132 | 133 | if (this.games[this.games.length - 1].movebufs.length > 0) { 134 | // We have movetext already, so this must be a new game. Start it. 135 | this.games.push(new_pgn_record()); 136 | } 137 | 138 | // Parse the tag line... 139 | 140 | tagline = tagline.slice(1, -1); // So now it's like: Foo "bar etc" 141 | 142 | let quote_i = tagline.indexOf(`"`); 143 | 144 | if (quote_i === -1) { 145 | continue; 146 | } 147 | 148 | let key = tagline.slice(0, quote_i).trim(); 149 | let value = tagline.slice(quote_i + 1).trim(); 150 | 151 | if (value.endsWith(`"`)) { 152 | value = value.slice(0, -1); 153 | } 154 | 155 | this.games[this.games.length - 1].tags[key] = SafeStringHTML(value); // Escape evil characters. IMPORTANT! 156 | 157 | } else { 158 | 159 | this.games[this.games.length - 1].movebufs.push(rawline); 160 | 161 | } 162 | 163 | if (this.n % 1000 === 0) { 164 | if (performance.now() - continuetime > 20) { 165 | this.msg = `Preparsing... ${(100 * (this.n / this.lines.length)).toFixed(0)}%`; 166 | setTimeout(() => {this.continue();}, 20); 167 | return; 168 | } 169 | } 170 | } 171 | 172 | // Once, after the while loop is broken... 173 | 174 | let cb = this.callback; cb(this.games); 175 | this.shutdown(); 176 | }; 177 | 178 | setTimeout(() => {loader.continue();}, 0); // setTimeout especially required here since there's no async load() function in this one. 179 | return loader; 180 | } 181 | 182 | // ------------------------------------------------------------------------------------------------------------------------------ 183 | 184 | function NewLineSplitter(buf, callback) { 185 | 186 | // The original sync version of this is in misc/scraps.js and is easier to read. 187 | 188 | let loader = Object.create(null); 189 | loader.type = "?"; 190 | 191 | loader.callback = callback; 192 | loader.msg = "PGN: Splitting..."; 193 | loader.lines = []; 194 | loader.buf = buf; 195 | 196 | loader.a = 0; 197 | loader.b = 0; 198 | 199 | if (buf.length > 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) { 200 | loader.a = 3; // 1st slice will skip byte order mark (BOM). 201 | } 202 | 203 | loader.shutdown = function() { 204 | this.callback = null; 205 | this.msg = ""; 206 | this.lines = null; 207 | this.buf = null; 208 | }; 209 | 210 | loader.append = function(arr) { 211 | if (arr.length > 0 && arr[arr.length - 1] === 13) { // Discard \r 212 | this.lines.push(Buffer.from(arr.slice(0, -1))); 213 | } else { 214 | this.lines.push(Buffer.from(arr)); 215 | } 216 | }; 217 | 218 | loader.continue = function() { 219 | 220 | if (!this.callback) { 221 | return; 222 | } 223 | 224 | let continuetime = performance.now(); 225 | 226 | while (true) { 227 | 228 | if (this.b >= this.buf.length) { 229 | break; 230 | } 231 | 232 | if (this.buf[this.b] === 10) { // Split on \n 233 | let line = this.buf.slice(this.a, this.b); 234 | this.append(line); 235 | this.a = this.b + 1; 236 | } 237 | 238 | this.b++; 239 | 240 | if (this.lines.length % 1000 === 0) { 241 | if (performance.now() - continuetime > 20) { 242 | this.msg = `PGN: Splitting... ${(100 * (this.b / this.buf.length)).toFixed(0)}%`; 243 | setTimeout(() => {this.continue();}, 20); 244 | return; 245 | } 246 | } 247 | } 248 | 249 | // Once, after the while loop is broken... 250 | 251 | if (this.a !== this.b) { // We haven't added the last line before EOF. 252 | let line = this.buf.slice(this.a, this.b); 253 | this.append(line); 254 | } 255 | 256 | let cb = this.callback; cb(this.lines); 257 | this.shutdown(); 258 | }; 259 | 260 | setTimeout(() => {loader.continue();}, 0); // setTimeout especially required here since there's no async load() function in this one. 261 | return loader; 262 | } 263 | 264 | // ------------------------------------------------------------------------------------------------------------------------------ 265 | 266 | function split_buffer_alternative(buf) { 267 | 268 | // Split a binary buffer into an array of binary buffers corresponding to lines. 269 | 270 | let lines = []; 271 | let search = Buffer.from("\n"); 272 | let off = 0; 273 | 274 | if (buf.length > 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) { 275 | off = 3; // Skip byte order mark (BOM). 276 | } 277 | 278 | while (true) { 279 | 280 | let hi = buf.indexOf(search, off); 281 | 282 | if (hi === -1) { 283 | if (off < buf.length) { 284 | lines.push(buf.slice(off)); 285 | } 286 | return lines; 287 | } 288 | 289 | if (buf[hi - 1] === 13) { // Discard \r 290 | lines.push(buf.slice(off, hi - 1)); 291 | } else { 292 | lines.push(buf.slice(off, hi)); 293 | } 294 | 295 | off = hi + 1; 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /files/misc/state_logic.txt: -------------------------------------------------------------------------------- 1 | State logic 2 | As of 2020-05-26: 3 | /-----------> halt() 4 | | 5 | 1 | 6 | "bestmove" --> move() --> position_changed() --> behave() -----> go() 7 | ^ | ^ ^ 8 | | | | | 9 | User changes POSITION --------/ \----> set_behaviour() | 10 | 2 ^ | 11 | | | 12 | User changes BEHAVIOUR ----------------------------/ | 13 | 3 | 14 | | 15 | User changes SEARCHMOVES -----> handle_searchmoves_change() ------/ 16 | | 17 | | 18 | User changes NODE LIMITS ------> handle_node_limit_change() ------/ 19 | 20 | 21 | Notes: 22 | 1. Only with relevant behaviour setting 23 | 2. This path only sets "halt" 24 | 3. Except that "analysis_locked" has its own function go_and_lock() 25 | -------------------------------------------------------------------------------- /files/res/linux/nibbler.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories=Game;BoardGame; 3 | Comment=Nibbler is a real-time analysis GUI for Leela Chess Zero (Lc0). 4 | Exec=nibbler 5 | GenericName=Chess Analysis GUI 6 | Icon=nibbler 7 | Name=Nibbler 8 | Terminal=false 9 | Type=Application 10 | -------------------------------------------------------------------------------- /files/res/nibbler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/res/nibbler.png -------------------------------------------------------------------------------- /files/res/nibbler.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /files/scripts/builder.py: -------------------------------------------------------------------------------- 1 | import json, os, shutil, zipfile 2 | 3 | zips = { 4 | "windows": "scripts/electron_zipped/electron-v9.4.4-win32-x64.zip", 5 | "linux": "scripts/electron_zipped/electron-v9.4.4-linux-x64.zip", 6 | } 7 | 8 | # To build Nibbler: (for info see https://electronjs.org/docs/tutorial/application-distribution) 9 | # 10 | # Obtain the appropriate Electron asset named above, from https://github.com/electron/electron/releases 11 | # Create a folder at scripts/electron_zipped and place the Electron asset in it 12 | # Run ./builder.py 13 | # 14 | # Note: later Electron versions work also, but with a couple minor glitches: 15 | # https://github.com/rooklift/nibbler/issues/140 16 | 17 | os.chdir(os.path.dirname(os.path.realpath(__file__))) # Ensure we're in builder.py's directory. 18 | os.chdir("..") # Then come up one level. 19 | 20 | with open("src/package.json") as f: 21 | version = json.load(f)["version"] 22 | 23 | for key, value in zips.items(): 24 | 25 | # check if electron archives exist 26 | if not os.path.exists(value): 27 | print("Skipping {} build: {} not present.".format(key, value)) 28 | continue 29 | 30 | # make build directory 31 | build_dir = "scripts/dist/nibbler-{}-{}".format(version, key) 32 | os.makedirs(build_dir) 33 | 34 | # copy files 35 | build_res_dir = os.path.join(build_dir, "resources") 36 | shutil.copytree("res", build_res_dir) 37 | shutil.copytree("src", os.path.join(build_res_dir, "app")) 38 | 39 | # extract electron 40 | print("Extracting for {}...".format(key)) 41 | z = zipfile.ZipFile(value, "r") 42 | z.extractall(build_dir) 43 | z.close() 44 | 45 | # rename executable 46 | if os.path.exists(os.path.join(build_dir, "electron.exe")): 47 | os.rename(os.path.join(build_dir, "electron.exe"), os.path.join(build_dir, "nibbler.exe")) 48 | if os.path.exists(os.path.join(build_dir, "electron")): 49 | os.rename(os.path.join(build_dir, "electron"), os.path.join(build_dir, "nibbler")) 50 | -------------------------------------------------------------------------------- /files/scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | BASE_URL="https://github.com/rooklift/nibbler" 5 | 6 | # check curl 7 | if ! which curl 1>/dev/null 2>&1 ; then 8 | echo "Please install curl and make sure it's added to \$PATH" 9 | echo "Aborting" 10 | exit 1 11 | fi 12 | 13 | # start 14 | echo "You are installing Nibbler" 15 | 16 | # get the latest release version 17 | VERSION=$(curl -fs -o /dev/null -w "%{redirect_url}" "${BASE_URL}/releases/latest" | xargs basename) 18 | echo "Latest release is ${VERSION}" 19 | ZIP_NAME="nibbler-${VERSION#v}-linux.zip" 20 | ZIP_URL="${BASE_URL}/releases/download/${VERSION}/${ZIP_NAME}" 21 | 22 | # create and enter temp dir 23 | TEMP_DIR=$(mktemp -d) 24 | cd "${TEMP_DIR}" 25 | 26 | # download 27 | echo "Downloading release from ${ZIP_URL}" 28 | if curl -fOL "${ZIP_URL}"; then 29 | echo "Successfully downloaded ${ZIP_NAME}" 30 | else 31 | echo "Failed to download ${ZIP_NAME}" 32 | echo "Exiting" 33 | exit 1 34 | fi 35 | 36 | # extract 37 | echo "Extracting..." 38 | unzip -q "${ZIP_NAME}" 39 | echo "Successfully extracted Nibbler" 40 | UNZIPPED_NAME="${ZIP_NAME%.zip}" 41 | 42 | # prepare 43 | chmod +x "${UNZIPPED_NAME}/nibbler" 44 | mv "${UNZIPPED_NAME}/resources/"{nibbler.png,nibbler.svg,linux} ./ 45 | 46 | # check if already installed 47 | INSTALL_DIR="/opt/nibbler" 48 | if [[ -d "${INSTALL_DIR}" ]]; then 49 | echo "${INSTALL_DIR} already exists!" 50 | echo "It looks like there is an existing installation of Nibbler on your system" 51 | read -p "Would you like to overwrite it? [y/n]" -n 1 CONFIRM_INSTALL 52 | echo 53 | if ! [[ "$CONFIRM_INSTALL" =~ ^[Yy]$ ]]; then 54 | echo "Aborting" 55 | exit 1 56 | fi 57 | fi 58 | 59 | # start install 60 | BIN_SYMLINK_PATH="/usr/local/bin/nibbler" 61 | DESKTOP_ENTRY_PATH="/usr/local/share/applications/nibbler.desktop" 62 | ICON_PNG_PATH="/usr/local/share/icons/hicolor/512x512/apps/nibbler.png" 63 | ICON_SVG_PATH="/usr/local/share/icons/hicolor/scalable/apps/nibbler.svg" 64 | echo "Installing Nibbler to ${INSTALL_DIR}" 65 | echo "Creating binary symlink at ${BIN_SYMLINK_PATH}" 66 | echo "Installing desktop entry to ${DESKTOP_ENTRY_PATH}" 67 | echo "Installing icons to ${ICON_PNG_PATH} and ${ICON_SVG_PATH}" 68 | echo "This will require sudo privilege." 69 | 70 | # remove old and make sure directories are created 71 | for FILE in "${INSTALL_DIR}" "${BIN_SYMLINK_PATH}" "${DESKTOP_ENTRY_PATH}" \ 72 | "${ICON_PNG_PATH}" "${ICON_SVG_PATH}"; do 73 | sudo rm -rf "$FILE" 74 | sudo mkdir -p $(dirname "$FILE") 75 | done 76 | 77 | # install new 78 | sudo mv "${UNZIPPED_NAME}" "${INSTALL_DIR}" 79 | sudo ln -s "${INSTALL_DIR}/nibbler" "${BIN_SYMLINK_PATH}" 80 | sudo mv "linux/nibbler.desktop" "${DESKTOP_ENTRY_PATH}" 81 | sudo mv "nibbler.png" "${ICON_PNG_PATH}" 82 | sudo mv "nibbler.svg" "${ICON_SVG_PATH}" 83 | 84 | # done 85 | echo "Successfully installed Nibbler ${VERSION}" 86 | echo "You will be able to find Nibbler in your launcher shortly" 87 | -------------------------------------------------------------------------------- /files/src/modules/alert_main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Exports a function we use to draw alert messages. 4 | // To be used in the main process only (so when the renderer needs to make an alert, it sends the message to main via IPC). 5 | 6 | const electron = require("electron"); 7 | const stringify = require("./stringify"); 8 | 9 | let major_version = (process && process.versions) ? parseInt(process.versions.electron, 10) : 0; 10 | 11 | if (Number.isNaN(major_version)) { 12 | major_version = 0; 13 | } 14 | 15 | let alerts_open = 0; 16 | 17 | module.exports = function(...args) { // Can be called as (msg) or as (win, msg) 18 | 19 | let win = (args.length < 2) ? undefined : args[0]; 20 | let msg = (args.length < 2) ? args[0] : args[1]; 21 | 22 | if (alerts_open >= 3) { 23 | console.log(msg); 24 | return; 25 | } 26 | 27 | alerts_open++; 28 | 29 | if (major_version <= 5) { 30 | 31 | // Old API. Providing a callback makes the window not block the process... 32 | // This is all rather untested. 33 | 34 | if (win) { 35 | electron.dialog.showMessageBox(win, {message: stringify(msg), title: "Alert", buttons: ["OK"]}, () => { 36 | alerts_open--; 37 | }); 38 | } else { 39 | electron.dialog.showMessageBox({message: stringify(msg), title: "Alert", buttons: ["OK"]}, () => { 40 | alerts_open--; 41 | }); 42 | } 43 | 44 | } else { 45 | 46 | // New promise-based API. Shouldn't block the process... 47 | 48 | if (win) { 49 | electron.dialog.showMessageBox(win, {message: stringify(msg), title: "Alert", buttons: ["OK"]}).then(() => { 50 | alerts_open--; 51 | }); 52 | } else { 53 | electron.dialog.showMessageBox({message: stringify(msg), title: "Alert", buttons: ["OK"]}).then(() => { 54 | alerts_open--; 55 | }); 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /files/src/modules/background.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function background(light, dark, square_size) { 4 | 5 | let c = document.createElement("canvas"); 6 | c.width = square_size * 8; 7 | c.height = square_size * 8; 8 | let ctx = c.getContext("2d"); 9 | 10 | for (let x = 0; x < 8; x++) { 11 | for (let y = 0; y < 8; y++) { 12 | ctx.fillStyle = (x + y) % 2 === 0 ? light : dark; 13 | ctx.fillRect(x * square_size, y * square_size, square_size, square_size); 14 | } 15 | } 16 | 17 | // I guess the canvas c gets garbage-collected? https://stackoverflow.com/questions/15320853 18 | 19 | return `url("${c.toDataURL("image/png")}")`; 20 | } 21 | 22 | module.exports = background; 23 | -------------------------------------------------------------------------------- /files/src/modules/config_io.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const electron = require("electron"); 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | const querystring = require("querystring"); 7 | 8 | const debork_json = require("./debork_json"); 9 | 10 | exports.filename = "config.json"; 11 | 12 | // To avoid using "remote", we rely on the main process passing userData location in the query... 13 | 14 | exports.filepath = electron.app ? 15 | path.join(electron.app.getPath("userData"), exports.filename) : // in Main process 16 | path.join(querystring.parse(global.location.search.slice(1))["user_data_path"], exports.filename); // in Renderer process 17 | 18 | function Config() {} // This exists solely to make instanceof work. 19 | Config.prototype = {}; 20 | 21 | exports.defaults = { 22 | "warning": "EDITING THIS FILE WHILE NIBBLER IS RUNNING WILL GENERALLY CAUSE YOUR EDITS TO BE LOST.", 23 | 24 | "language": "English", 25 | 26 | "path": null, // Not undefined, all normal keys should have an actual value. 27 | 28 | "args_unused": null, 29 | "options_unused": null, 30 | 31 | "disable_hw_accel": false, 32 | 33 | "width": 1280, 34 | "height": 835, 35 | "board_size": 640, 36 | "info_font_size": 16, 37 | "pgn_font_size": 16, 38 | "fen_font_size": 16, 39 | "arrow_width": 8, 40 | "arrowhead_radius": 12, 41 | "board_font": "18px Arial", 42 | 43 | "graph_height": 96, 44 | "graph_line_width": 2, 45 | "graph_minimum_length": 41, // Desired depth + 1 46 | 47 | "light_square": "#dadada", 48 | "dark_square": "#b4b4b4", 49 | "active_square": "#66aaaa", 50 | "move_squares_with_alpha": "#ffff0026", 51 | 52 | "best_colour": "#66aaaa", 53 | "good_colour": "#66aa66", 54 | "bad_colour": "#cccc66", 55 | "terrible_colour": "#cc6666", 56 | "actual_move_colour": "#cc9966", 57 | 58 | "searchmoves_buttons": true, 59 | "focus_on_text": "focused:", 60 | "focus_off_text": "focus?", 61 | 62 | "accept_bounds": false, 63 | "max_info_lines": null, // Hidden option 64 | 65 | "bad_move_threshold": 0.02, 66 | "terrible_move_threshold": 0.04, 67 | "ab_filter_threshold": 0.1, 68 | 69 | "arrow_filter_type": "N", 70 | "arrow_filter_value": 0.01, 71 | 72 | "arrows_enabled": true, 73 | "click_spotlight": true, 74 | "next_move_arrow": false, 75 | "next_move_outline": false, 76 | "next_move_unique_colour": false, 77 | "arrowhead_type": 0, 78 | 79 | "ev_pov": null, 80 | "cp_pov": null, 81 | "wdl_pov": null, 82 | 83 | "show_cp": false, 84 | "show_n": true, 85 | "show_n_abs": true, 86 | "show_depth": true, 87 | "show_p": true, 88 | "show_v": false, 89 | "show_q": false, 90 | "show_u": false, 91 | "show_s": false, 92 | "show_m": false, 93 | "show_wdl": true, 94 | "infobox_stats_newline": false, 95 | "infobox_pv_move_numbers": false, 96 | "hover_draw": false, 97 | "hover_method": 2, 98 | 99 | "looker_api": null, 100 | "look_past_25": false, 101 | 102 | "pv_click_event": 1, // 0: nothing, 1: goto, 2: tree 103 | 104 | "pgn_ev": true, 105 | "pgn_cp": false, 106 | "pgn_n": true, 107 | "pgn_n_abs": false, 108 | "pgn_of_n": true, 109 | "pgn_depth": false, 110 | "pgn_p": false, 111 | "pgn_v": false, 112 | "pgn_q": false, 113 | "pgn_u": false, 114 | "pgn_s": false, 115 | "pgn_m": false, 116 | "pgn_wdl": false, 117 | 118 | "pgn_dialog_folder": "", 119 | "engine_dialog_folder": "", 120 | "weights_dialog_folder": "", 121 | "evalfile_dialog_folder": "", 122 | "syzygy_dialog_folder": "", 123 | "pieces_dialog_folder": "", 124 | "background_dialog_folder": "", 125 | "book_dialog_folder": "", 126 | 127 | "update_delay": 170, 128 | "animate_delay_multiplier": 4, 129 | 130 | "allow_arbitrary_scripts": false, 131 | "ignore_filesize_limits": false, 132 | "allow_stopped_analysis": false, 133 | "never_suppress_searchmoves": true, 134 | "never_grayout_infolines": false, 135 | "suppress_ucinewgame": false, 136 | 137 | "show_engine_state": false, 138 | 139 | "book_depth": 10, 140 | 141 | "save_enabled": false, 142 | "override_piece_directory": null, 143 | "override_board": null, 144 | 145 | "leelaish_names": ["Lc0", "Leela", "Ceres"], // If this gets updated, will need to fix old config files. 146 | 147 | "logfile": null, 148 | "clear_log": true, 149 | "logfile_timestamp": false, 150 | "log_info_lines": false, 151 | "log_useless_info": false, 152 | "log_illegal_moves": true, 153 | "log_positions": true, 154 | }; 155 | 156 | function fix(cfg) { 157 | 158 | // We want to create a few temporary things (not saved to file)... 159 | 160 | cfg.flip = false; 161 | cfg.behaviour = "halt"; 162 | cfg.square_size = Math.floor(cfg.board_size / 8); 163 | 164 | // Make sure objectish things at least exist... 165 | 166 | if (Array.isArray(cfg.leelaish_names) === false) { 167 | cfg.leelaish_names = Array.from(exports.defaults.leelaish_names); 168 | } 169 | 170 | // Fix the board size... 171 | 172 | cfg.board_size = cfg.square_size * 8; 173 | 174 | // The uncertainty_cutoff key was removed. Filtering by U was also removed... 175 | 176 | if (cfg.uncertainty_cutoff !== undefined || cfg.arrow_filter_type === "U") { 177 | cfg.arrow_filter_type = "N"; 178 | cfg.arrow_filter_value = exports.defaults.arrow_filter_value; 179 | } 180 | 181 | // This can't be 0 because we divide by it... 182 | 183 | cfg.animate_delay_multiplier = Math.floor(cfg.animate_delay_multiplier); 184 | 185 | if (cfg.animate_delay_multiplier <= 0) { 186 | cfg.animate_delay_multiplier = 1; 187 | } 188 | 189 | // We used to expect font sizes to be strings with "px"... 190 | 191 | for (let key of ["info_font_size", "pgn_font_size", "fen_font_size"]) { 192 | if (typeof cfg[key] === "string") { 193 | cfg[key] = parseInt(cfg[key], 10); // Works even if string ends with "px" 194 | if (Number.isNaN(cfg[key])) { 195 | cfg[key] = exports.defaults[key]; 196 | } 197 | } 198 | } 199 | 200 | // Convert any strings of "false", "true" and "null"... 201 | 202 | for (let key of Object.keys(cfg)) { 203 | if (typeof cfg[key] === "string") { 204 | if (cfg[key].toLowerCase() === "true") { 205 | cfg[key] = true; 206 | } else if (cfg[key].toLowerCase() === "false") { 207 | cfg[key] = false; 208 | } else if (cfg[key].toLowerCase() === "null") { 209 | cfg[key] = null; 210 | } 211 | } 212 | } 213 | 214 | // These things need to be strings. They are used as defaultPath parameters 215 | // but versions of Electron >= 6 (I think) crash when they aren't strings. 216 | // Sadly we defaulted them to null in 1.2.1 so bad config files may exist. 217 | 218 | if (typeof cfg.pgn_dialog_folder !== "string") { 219 | cfg.pgn_dialog_folder = ""; 220 | } 221 | if (typeof cfg.engine_dialog_folder !== "string") { 222 | cfg.engine_dialog_folder = ""; 223 | } 224 | if (typeof cfg.weights_dialog_folder !== "string") { 225 | cfg.weights_dialog_folder = ""; 226 | } 227 | 228 | // These three vars were replaced... 229 | 230 | if (cfg.ev_white_pov) { 231 | cfg.ev_pov = "w"; 232 | } 233 | if (cfg.cp_white_pov) { 234 | cfg.cp_pov = "w"; 235 | } 236 | if (cfg.wdl_white_pov) { 237 | cfg.wdl_pov = "w"; 238 | } 239 | 240 | // Too many people are setting this... 241 | 242 | cfg.show_engine_state = exports.defaults.show_engine_state; 243 | } 244 | 245 | exports.load = () => { 246 | 247 | let cfg = new Config(); 248 | 249 | let err_to_return = null; 250 | 251 | try { 252 | if (fs.existsSync(exports.filepath)) { 253 | let raw = fs.readFileSync(exports.filepath, "utf8"); 254 | try { 255 | Object.assign(cfg, JSON.parse(raw)); 256 | } catch (err) { 257 | console.log(exports.filename, err.toString(), "...trying to debork..."); 258 | Object.assign(cfg, JSON.parse(debork_json(raw))); 259 | } 260 | } 261 | } catch (err) { 262 | console.log(err.toString()); // alert() might not be available. 263 | err_to_return = err.toString(); 264 | } 265 | 266 | // Copy default values for any missing keys into the config... 267 | // We use a copy so that any objects that are assigned are not the default objects. 268 | 269 | let defaults_copy = JSON.parse(JSON.stringify(exports.defaults)); 270 | 271 | for (let key of Object.keys(defaults_copy)) { 272 | if (cfg.hasOwnProperty(key) === false) { 273 | cfg[key] = defaults_copy[key]; 274 | } 275 | } 276 | 277 | fix(cfg); 278 | return [err_to_return, cfg]; 279 | }; 280 | 281 | exports.save = (cfg) => { 282 | 283 | if (cfg instanceof Config === false) { 284 | throw "Wrong type of object sent to config_io.save()"; 285 | } 286 | 287 | // Make a copy of the defaults. Doing it this way seems to 288 | // ensure the final JSON string has the same ordering... 289 | 290 | let out = JSON.parse(JSON.stringify(exports.defaults)); 291 | 292 | // Adjust that copy, but only for keys present in both. 293 | 294 | for (let key of Object.keys(cfg)) { 295 | if (out.hasOwnProperty(key)) { 296 | out[key] = cfg[key]; 297 | } 298 | } 299 | 300 | try { 301 | fs.writeFileSync(exports.filepath, JSON.stringify(out, null, "\t")); 302 | } catch (err) { 303 | console.log(err.toString()); // alert() might not be available. 304 | } 305 | }; 306 | 307 | exports.create_if_needed = (cfg) => { 308 | 309 | // Note that this must be called fairly late, when userData directory exists. 310 | 311 | if (cfg instanceof Config === false) { 312 | throw "Wrong type of object sent to config_io.create_if_needed()"; 313 | } 314 | 315 | if (fs.existsSync(exports.filepath)) { 316 | return; 317 | } 318 | 319 | exports.save(cfg); 320 | }; 321 | -------------------------------------------------------------------------------- /files/src/modules/custom_uci.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const electron = require("electron"); 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | const querystring = require("querystring"); 7 | 8 | const scripts_dir = "scripts"; 9 | const example_file = "example.txt"; 10 | 11 | const example = 12 | `setoption name Something value WhoKnows 13 | setoption name Example value Whatever`; 14 | 15 | // To avoid using "remote", we rely on the main process passing userData location in the query... 16 | 17 | exports.script_dir_path = electron.app ? 18 | path.join(electron.app.getPath("userData"), scripts_dir) : 19 | path.join(querystring.parse(global.location.search.slice(1))["user_data_path"], scripts_dir); 20 | 21 | exports.load = () => { 22 | 23 | try { 24 | let files = fs.readdirSync(exports.script_dir_path); 25 | 26 | let ret = []; 27 | 28 | for (let file of files) { 29 | ret.push({ 30 | name: file, 31 | path: path.join(exports.script_dir_path, file) 32 | }); 33 | } 34 | 35 | return ret; 36 | 37 | } catch (err) { 38 | 39 | return [ 40 | { 41 | name: example_file, 42 | path: path.join(exports.script_dir_path, example_file) 43 | } 44 | ]; 45 | 46 | } 47 | }; 48 | 49 | exports.create_if_needed = () => { 50 | 51 | // Note that this must be called fairly late, when userData directory exists. 52 | 53 | try { 54 | if (!fs.existsSync(exports.script_dir_path)) { 55 | fs.mkdirSync(exports.script_dir_path); 56 | let example_path = path.join(exports.script_dir_path, example_file); 57 | fs.writeFileSync(example_path, example); 58 | } 59 | } catch (err) { 60 | console.log(err.toString()); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /files/src/modules/debork_json.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function replace_all(s, search, replace) { 4 | if (!s.includes(search)) { // Seems to improve speed overall. 5 | return s; 6 | } 7 | return s.split(search).join(replace); 8 | } 9 | 10 | function debork_json(s) { 11 | 12 | // Convert totally blank files into {} 13 | 14 | if (s.length < 50 && s.trim() === "") { 15 | s = "{}"; 16 | } 17 | 18 | // Replace fruity quote characters. Note that these could exist in legit JSON, 19 | // which is why we only call this function if the first parse fails... 20 | 21 | s = replace_all(s, '“', '"'); 22 | s = replace_all(s, '”', '"'); 23 | 24 | // Replace single \ characters 25 | 26 | s = replace_all(s, "\\\\", "correct_zbcyg278gfdakjadjk"); 27 | s = replace_all(s, "\\", "\\\\"); 28 | s = replace_all(s, "correct_zbcyg278gfdakjadjk", "\\\\"); 29 | 30 | return s; 31 | } 32 | 33 | module.exports = debork_json; 34 | -------------------------------------------------------------------------------- /files/src/modules/empty.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // The most perfect JS module ever written. Extensive testing 4 | // shows that this module contains less than 20 bugs. 5 | -------------------------------------------------------------------------------- /files/src/modules/engineconfig_io.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const electron = require("electron"); 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | const querystring = require("querystring"); 7 | 8 | const debork_json = require("./debork_json"); 9 | 10 | exports.filename = "engines.json"; 11 | 12 | // To avoid using "remote", we rely on the main process passing userData location in the query... 13 | 14 | exports.filepath = electron.app ? 15 | path.join(electron.app.getPath("userData"), exports.filename) : // in Main process 16 | path.join(querystring.parse(global.location.search.slice(1))["user_data_path"], exports.filename); // in Renderer process 17 | 18 | function EngineConfig() {} // This exists solely to make instanceof work. 19 | EngineConfig.prototype = {}; 20 | 21 | // --------------------------------------------------------------------------------------------------------------------------- 22 | 23 | function fix(cfg) { 24 | 25 | // The nameless dummy that hub creates at startup needs an entry... 26 | 27 | cfg[""] = exports.newentry(); 28 | 29 | // Fix any saved entries present in the file... 30 | 31 | for (let key of Object.keys(cfg)) { 32 | if (typeof cfg[key] !== "object" || cfg[key] === null) { 33 | cfg[key] = exports.newentry(); 34 | } 35 | if (Array.isArray(cfg[key].args) === false) { 36 | cfg[key].args = []; 37 | } 38 | if (typeof cfg[key].options !== "object" || cfg[key].options === null) { 39 | cfg[key].options = {}; 40 | } 41 | if (typeof cfg[key].limit_by_time !== "boolean") { 42 | cfg[key].limit_by_time = cfg[key].limit_by_time ? true : false; 43 | } 44 | 45 | // We don't really care about missing search_nodes and search_nodes_special properties. (?) 46 | } 47 | } 48 | 49 | exports.newentry = () => { 50 | return { 51 | "args": [], 52 | "options": {}, 53 | "search_nodes": null, 54 | "search_nodes_special": 10000, 55 | "limit_by_time": false, 56 | }; 57 | }; 58 | 59 | exports.load = () => { 60 | 61 | let cfg = new EngineConfig(); 62 | 63 | let err_to_return = null; 64 | 65 | try { 66 | if (fs.existsSync(exports.filepath)) { 67 | let raw = fs.readFileSync(exports.filepath, "utf8"); 68 | try { 69 | Object.assign(cfg, JSON.parse(raw)) 70 | } catch (err) { 71 | console.log(exports.filename, err.toString(), "...trying to debork..."); 72 | Object.assign(cfg, JSON.parse(debork_json(raw))); 73 | } 74 | } 75 | } catch (err) { 76 | console.log(err.toString()); // alert() might not be available. 77 | err_to_return = err.toString(); 78 | } 79 | 80 | fix(cfg); 81 | return [err_to_return, cfg]; 82 | }; 83 | 84 | exports.save = (cfg) => { 85 | 86 | if (cfg instanceof EngineConfig === false) { 87 | throw "Wrong type of object sent to engineconfig_io.save()"; 88 | } 89 | 90 | let blank = cfg[""]; 91 | delete cfg[""]; 92 | 93 | try { 94 | fs.writeFileSync(exports.filepath, JSON.stringify(cfg, null, "\t")); 95 | } catch (err) { 96 | console.log(err.toString()); // alert() might not be available. 97 | } 98 | 99 | cfg[""] = blank; 100 | }; 101 | 102 | exports.create_if_needed = (cfg) => { 103 | 104 | // Note that this must be called fairly late, when userData directory exists. 105 | 106 | if (cfg instanceof EngineConfig === false) { 107 | throw "Wrong type of object sent to engineconfig_io.create_if_needed()"; 108 | } 109 | 110 | if (fs.existsSync(exports.filepath)) { 111 | return; 112 | } 113 | 114 | exports.save(cfg); 115 | }; 116 | -------------------------------------------------------------------------------- /files/src/modules/images.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | 6 | let sprites = { 7 | 8 | loads: 0, 9 | 10 | fully_loaded: function() { 11 | return this.loads === 12; 12 | }, 13 | 14 | validate_folder: function(directory) { 15 | 16 | if (typeof directory !== "string") { 17 | return false; 18 | } 19 | 20 | for (let c of "KkQqRrBbNnPp") { 21 | 22 | if (!fs.existsSync(path.join(directory, `${c.toUpperCase()}.svg`))) { 23 | if (!fs.existsSync(path.join(directory, `${c.toUpperCase()}.png`))) { 24 | return false; 25 | } 26 | } 27 | 28 | if (!fs.existsSync(path.join(directory, `_${c.toUpperCase()}.svg`))) { 29 | if (!fs.existsSync(path.join(directory, `_${c.toUpperCase()}.png`))) { 30 | return false; 31 | } 32 | } 33 | } 34 | 35 | return true; 36 | }, 37 | 38 | load_from: function(directory) { 39 | 40 | let urlsafe_directory = directory.replace(/#/g, "%23"); // Looks like replacing # with %23 is the only thing that's needed? Maybe some others?? 41 | 42 | sprites.loads = 0; 43 | 44 | for (let c of "KkQqRrBbNnPp") { 45 | 46 | sprites[c] = new Image(); 47 | sprites[c].addEventListener("load", () => {sprites.loads++;}, {once: true}); 48 | 49 | if (c === c.toUpperCase()) { 50 | 51 | sprites[c].addEventListener("error", () => {console.log(`Failed to load image ${c}.svg or ${c}.png`);}, {once: true}); 52 | 53 | if (fs.existsSync(path.join(directory, `${c}.svg`))) { 54 | sprites[c].src = path.join(urlsafe_directory, `${c}.svg`); 55 | } else if (fs.existsSync(path.join(directory, `${c}.png`))) { 56 | sprites[c].src = path.join(urlsafe_directory, `${c}.png`); 57 | } 58 | 59 | } else { 60 | 61 | sprites[c].addEventListener("error", () => {console.log(`Failed to load image _${c.toUpperCase()}.svg or _${c.toUpperCase()}.png`);}, {once: true}); 62 | 63 | if (fs.existsSync(path.join(directory, `_${c.toUpperCase()}.svg`))) { 64 | sprites[c].src = path.join(urlsafe_directory, `_${c.toUpperCase()}.svg`); 65 | } else if (fs.existsSync(path.join(directory, `_${c.toUpperCase()}.png`))) { 66 | sprites[c].src = path.join(urlsafe_directory, `_${c.toUpperCase()}.png`); 67 | } 68 | } 69 | 70 | // Note that, after the src is set above, it is automatically changed by the JS engine to be something like 71 | // "file:///C:/foo/bar/whatever.png" 72 | 73 | sprites[c].string_for_bg_style = `url("${sprites[c].src}")`; // Since the src path won't contain " this should be safe. 74 | } 75 | }, 76 | }; 77 | 78 | module.exports = sprites; 79 | -------------------------------------------------------------------------------- /files/src/modules/messages.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const config_io = require("./config_io"); 4 | const engineconfig_io = require("./engineconfig_io"); 5 | 6 | 7 | exports.about_versus_mode = `The "play this colour" option causes Leela to \ 8 | evaluate one side of the position only. The top move is automatically played on \ 9 | the board upon reaching the node limit (see the Engine menu). This allows you to \ 10 | play against Leela. 11 | 12 | The "self-play" option causes Leela to play itself. 13 | 14 | Higher temperature makes the moves less predictable, but at some cost to move \ 15 | correctness. Meanwhile, TempDecayMoves specifies how many moves the temperature \ 16 | effect lasts for. These settings have no effect on analysis, only actual move \ 17 | generation.`; 18 | 19 | 20 | exports.save_not_enabled = `Save is disabled until you read the following \ 21 | warning. 22 | 23 | Nibbler does not append to PGN files, nor does it save collections. It only \ 24 | writes the current game to file. When you try to save, you will be prompted with \ 25 | a standard "Save As" dialog. If you save to a file that already exists, that \ 26 | file will be DESTROYED and REPLACED with a file containing only the current \ 27 | game. 28 | 29 | You can enable save in the dev menu.`; 30 | 31 | 32 | exports.engine_not_present = `Engine not found. Please find the engine via the \ 33 | Engine menu. You might also need to locate the weights (neural network) file.`; 34 | 35 | 36 | exports.engine_failed_to_start = `Engine failed to start.`; 37 | 38 | 39 | exports.uncaught_exception = `There may have been an uncaught exception. If you \ 40 | could open the dev tools and the console tab therein, and report the contents to \ 41 | the author (ideally with a screenshot) that would be grand.`; 42 | 43 | 44 | exports.renderer_crash = `The renderer process has crashed. Experience suggests \ 45 | this happens when Leela runs out of RAM. If this doesn't apply, please tell the \ 46 | author how you made it happen.`; 47 | 48 | 49 | exports.renderer_hang = `The renderer process may have hung. Please tell the \ 50 | author how you made this happen.`; 51 | 52 | 53 | exports.about_sizes = `You can get more fine-grained control of font, board, \ 54 | graph, and window sizes via Nibbler's config file (which can be found via the \ 55 | Dev menu).`; 56 | 57 | 58 | exports.about_hashes = `You can set the Hash value directly via Nibbler's \ 59 | ${engineconfig_io.filename} file (which can be found via the Dev menu).`; 60 | 61 | 62 | exports.thread_warning = `Note that, for systems using a GPU, 2 threads is usually \ 63 | sufficient for Leela, and increasing this number can actually make Leela weaker! \ 64 | More threads should probably only be used on CPU-only systems, if at all. 65 | 66 | If no tick is present in this menu, the default is being used, which is probably \ 67 | what you want.`; 68 | 69 | 70 | exports.adding_scripts = `Nibbler has a scripts folder, inside which you can \ 71 | place scripts of raw input to send to the engine. A small example file is \ 72 | provided. This is for advanced users and devs who understand the UCI protocol. 73 | 74 | Note that this is for configuration only.`; 75 | 76 | 77 | exports.invalid_script = `Bad script; scripts are for configuration only.`; 78 | 79 | 80 | exports.wrong_engine_exe = `That is almost certainly the wrong file. What we \ 81 | need is likely to be called lc0.exe or lc0.`; 82 | 83 | 84 | exports.send_fail = `Sending to the engine failed. This usually means it has \ 85 | crashed.`; 86 | 87 | 88 | exports.invalid_pieces_directory = `Did not find all pieces required!`; 89 | 90 | 91 | exports.about_custom_pieces = `To use a custom piece set, select a folder \ 92 | containing SVG or PNG files with names such as "Q.png" (or "Q.svg") for white \ 93 | and "_Q.png" (or "_Q.svg") for black.`; 94 | 95 | 96 | exports.desync = `Desync... (restart engine via Engine menu)`; 97 | 98 | 99 | exports.c960_warning = `We appear to have entered a game of Chess960, however \ 100 | this engine does not support Chess960. Who knows what will happen. Probably not \ 101 | good things. Maybe bad things.`; 102 | 103 | 104 | exports.bad_bin_book = `This book contained unsorted keys and is therefore not a \ 105 | valid Polyglot book.`; 106 | 107 | 108 | exports.file_too_big = `Sorry, this file is probably too large to be safely \ 109 | loaded in Nibbler. If you want, you can suppress this warning in the Dev menu, \ 110 | and try to load the file anyway.`; 111 | 112 | 113 | exports.pgn_book_too_big = `This file is impractically large for a PGN book - \ 114 | consider converting it to Polyglot (.bin) format. If you want, you can suppress \ 115 | this warning in the Dev menu, and try to load the file anyway.`; 116 | 117 | 118 | exports.engine_options_reset = `As of v2.1.1, Nibbler will store engine options \ 119 | separately for each engine. To facilite this, your engine options have been \ 120 | reset. If you were using special (hand-edited) options, they are still present \ 121 | in your ${config_io.filename} file, and can be manually moved to \ 122 | ${engineconfig_io.filename}.`; 123 | 124 | 125 | exports.too_soon_to_set_options = `Please wait till the engine has loaded before \ 126 | setting options.`; 127 | 128 | -------------------------------------------------------------------------------- /files/src/modules/running_as_electron.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const path = require("path"); 4 | 5 | // Is there not a better way? Perhaps some Electron API somewhere? 6 | 7 | module.exports = () => { 8 | return path.basename(process.argv[0]).toLowerCase().includes("electron"); 9 | }; 10 | -------------------------------------------------------------------------------- /files/src/modules/stringify.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Given anything, create a string from it. 4 | // Helps with sending messages over IPC, displaying alerts, etc. 5 | 6 | module.exports = (msg) => { 7 | 8 | try { 9 | 10 | if (msg instanceof Error) { 11 | msg = msg.toString(); 12 | } 13 | if (typeof msg === "object") { 14 | msg = JSON.stringify(msg); 15 | } 16 | if (typeof msg === "undefined") { 17 | msg = "undefined"; 18 | } 19 | msg = msg.toString().trim(); 20 | return msg; 21 | 22 | } catch (err) { 23 | 24 | return "stringify() failed"; 25 | 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /files/src/modules/translate.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const translations = require("./translations"); 4 | 5 | let startup_language = null; 6 | 7 | exports.register_startup_language = function(s) { // Will have to be called in both processes (assuming renderer ever uses this at all). 8 | startup_language = s; 9 | } 10 | 11 | exports.translate = function(key, force_language = null) { 12 | 13 | // Note that we usually use the language which was in config.json at startup so 14 | // that in-flight calls to translate() return consistent results even if the user 15 | // switches config.language at some point. (Thus, the user will need to restart 16 | // to see any change.) 17 | 18 | let language = force_language || startup_language; 19 | 20 | if (translations[language] && translations[language][key]) { 21 | return translations[language][key]; 22 | } else { 23 | return key; 24 | } 25 | } 26 | 27 | exports.t = exports.translate; 28 | 29 | exports.all_languages = function() { 30 | return Object.keys(translations); 31 | } 32 | 33 | -------------------------------------------------------------------------------- /files/src/nibbler.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | background-color: #080808; 7 | border: 0; 8 | color: #eeeeee; 9 | cursor: default; 10 | margin: 0; 11 | overflow: hidden; 12 | padding: 0; 13 | pointer-events: none; /* These must be overriden for things that need pointer / select */ 14 | user-select: none; /* These must be overriden for things that need pointer / select */ 15 | } 16 | 17 | ::-webkit-scrollbar { 18 | pointer-events: auto; 19 | background-color: #181818; 20 | } 21 | 22 | ::-webkit-scrollbar-thumb { 23 | pointer-events: auto; 24 | background-color: #444444; 25 | } 26 | 27 | #gridder { 28 | display: grid; 29 | height: 100vh; 30 | grid-template-columns: min-content 1fr; 31 | grid-template-rows: min-content min-content 1fr; 32 | grid-template-areas: 33 | "a b" 34 | "f f" 35 | "g g"; 36 | } 37 | 38 | #rightgridder { 39 | grid-area: b; 40 | display: grid; 41 | margin: 1em 0 0 0; 42 | height: 0; /* js needs to keep this equal to the boardsize */ 43 | grid-template-columns: none; 44 | grid-template-rows: min-content 1fr min-content; 45 | grid-template-areas: 46 | "c" 47 | "d" 48 | "e"; 49 | } 50 | 51 | #boardsquares { 52 | grid-area: a; 53 | margin: 1em 0 0 1em; 54 | background-size: cover; 55 | border-collapse: collapse; 56 | table-layout: fixed; 57 | z-index: 1; 58 | } 59 | 60 | #canvas { 61 | grid-area: a; 62 | margin: 1em 0 0 1em; 63 | display: block; 64 | outline-offset: 6px; 65 | z-index: 2; 66 | } 67 | 68 | #boardfriends { 69 | grid-area: a; 70 | margin: 1em 0 0 1em; 71 | border-collapse: collapse; 72 | pointer-events: auto; 73 | table-layout: fixed; 74 | z-index: 3; 75 | } 76 | 77 | #statusbox { 78 | grid-area: c; 79 | margin: 0 0 0 1em; 80 | border: none; 81 | display: block; 82 | font-family: monospace, monospace; 83 | pointer-events: auto; 84 | overflow: hidden; 85 | white-space: pre; 86 | } 87 | 88 | #infobox { 89 | grid-area: d; 90 | margin: 1em 1em 0 1em; 91 | display: block; 92 | color: #cccccc; /* only used for Lc0 stderr output at startup */ 93 | font-family: monospace, monospace; 94 | overflow-x: hidden; 95 | overflow-y: auto; 96 | padding-right: 10px; /* so the text doesn't get so near the scroll bar */ 97 | pointer-events: auto; 98 | white-space: pre-wrap; 99 | } 100 | 101 | #graph { 102 | grid-area: e; 103 | align-self: end; 104 | display: block; 105 | margin: 10px 0 0 1em; 106 | pointer-events: auto; 107 | } 108 | 109 | input[type=text]:focus { 110 | outline: 2px dashed gray; 111 | outline-offset: 4px; 112 | } 113 | 114 | #fenbox { 115 | grid-area: f; 116 | margin: 1em 1em 0 1em; 117 | background-color: #080808; 118 | border: none; 119 | caret-color: white; 120 | color: #6cccee; 121 | display: block; 122 | font-family: monospace, monospace; 123 | font-size: 100%; 124 | pointer-events: auto; 125 | user-select: auto; 126 | } 127 | 128 | #movelist { 129 | grid-area: g; 130 | margin: 1em 1em 1em 1em; 131 | display: block; 132 | color: #999999; 133 | font-family: monospace, monospace; 134 | overflow-x: hidden; 135 | overflow-y: auto; 136 | padding-right: 10px; /* so the text doesn't get so near the scroll bar */ 137 | pointer-events: auto; 138 | white-space: pre-wrap; 139 | } 140 | 141 | #promotiontable { 142 | border-collapse: collapse; 143 | display: none; 144 | pointer-events: auto; 145 | position: fixed; 146 | table-layout: fixed; 147 | z-index: 4; 148 | } 149 | 150 | #fullbox { 151 | background-color: #080808; 152 | display: none; /* better than visibility: hidden - never intercepts inputs */ 153 | font-family: monospace, monospace; 154 | font-size: 100%; 155 | height: 100%; 156 | left: 0; 157 | overflow-y: auto; 158 | pointer-events: auto; 159 | position: fixed; 160 | top: 0; 161 | width: 100%; 162 | z-index: 6; 163 | } 164 | 165 | #fullbox_content { 166 | overflow: hidden; 167 | padding: 1em; 168 | white-space: pre; 169 | } 170 | 171 | td { 172 | background-color: transparent; 173 | background-size: contain; 174 | border: 0; 175 | margin: 0; 176 | padding: 0; 177 | } 178 | 179 | a, a:link, a:visited, a:hover, a:active { /* I think this is now only used for the "Nibbler in normal browser" message. */ 180 | color: #6cccee; 181 | } 182 | 183 | ul { 184 | list-style: none; 185 | } 186 | 187 | .pink { 188 | color: #ffaaaa; 189 | } 190 | 191 | .white { 192 | color: #eeeeee; 193 | } 194 | 195 | .gray { 196 | color: #999999; 197 | } 198 | 199 | .darkgray { 200 | color: #666666; 201 | } 202 | 203 | .red { 204 | color: #ff6666; 205 | } 206 | 207 | .yellow { 208 | color: #ffff00; 209 | } 210 | 211 | .green { 212 | color: #66ff66; 213 | } 214 | 215 | .blue { 216 | color: #6cccee; 217 | } 218 | 219 | .infoline { 220 | margin-bottom: 1em; 221 | } 222 | 223 | .enginechooser { 224 | margin-bottom: 1em; 225 | } 226 | 227 | .enginechooser:hover { 228 | color: #6cccee; 229 | } 230 | 231 | .pgnchooser:hover { 232 | background-color: #202020; 233 | } 234 | 235 | .ocm_highlight { 236 | background-color: #770000; 237 | } 238 | 239 | .hover_highlight { 240 | background-color: #222244; 241 | } 242 | 243 | span.movelist_highlight_blue { 244 | background-color: #222244; 245 | color: #6cccee; 246 | } 247 | 248 | span.movelist_highlight_yellow { 249 | background-color: #444422; 250 | color: #ffff00; 251 | } 252 | 253 | span.nobr { 254 | white-space: nowrap; /* Used for O-O and O-O-O moves */ 255 | } 256 | -------------------------------------------------------------------------------- /files/src/nibbler.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Nibbler 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 |
Starting up...
24 |
25 | 26 | 27 |
28 | 29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /files/src/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nibbler", 3 | "version": "2.5.3", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /files/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nibbler", 3 | "version": "2.5.3", 4 | "author": "Rooklift", 5 | "description": "Leela Chess Zero (Lc0) interface", 6 | "license": "GPL-3.0", 7 | "main": "main.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/rooklift/nibbler" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /files/src/pieces/B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/B.png -------------------------------------------------------------------------------- /files/src/pieces/K.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/K.png -------------------------------------------------------------------------------- /files/src/pieces/N.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/N.png -------------------------------------------------------------------------------- /files/src/pieces/P.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/P.png -------------------------------------------------------------------------------- /files/src/pieces/Q.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/Q.png -------------------------------------------------------------------------------- /files/src/pieces/R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/R.png -------------------------------------------------------------------------------- /files/src/pieces/_B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/_B.png -------------------------------------------------------------------------------- /files/src/pieces/_K.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/_K.png -------------------------------------------------------------------------------- /files/src/pieces/_N.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/_N.png -------------------------------------------------------------------------------- /files/src/pieces/_P.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/_P.png -------------------------------------------------------------------------------- /files/src/pieces/_Q.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/_Q.png -------------------------------------------------------------------------------- /files/src/pieces/_R.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rooklift/nibbler/d9abf88d5813eff0f6bc1b287f75a2a2741f6ac3/files/src/pieces/_R.png -------------------------------------------------------------------------------- /files/src/renderer/10_globals.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // HTML stuff....................................................... 4 | // 5 | // All of this may be redundant since id-havers are in the global 6 | // namespace automatically. But declaring them const has some value. 7 | 8 | const boardfriends = document.getElementById("boardfriends"); 9 | const boardsquares = document.getElementById("boardsquares"); 10 | const canvas = document.getElementById("canvas"); 11 | const fenbox = document.getElementById("fenbox"); 12 | const graph = document.getElementById("graph"); 13 | const rightgridder = document.getElementById("rightgridder"); 14 | const infobox = document.getElementById("infobox"); 15 | const movelist = document.getElementById("movelist"); 16 | const fullbox = document.getElementById("fullbox"); 17 | const fullbox_content = document.getElementById("fullbox_content"); 18 | const promotiontable = document.getElementById("promotiontable"); 19 | const statusbox = document.getElementById("statusbox"); 20 | 21 | // If require isn't available, we're in a browser: 22 | 23 | try { 24 | require("./modules/empty"); 25 | } catch (err) { 26 | statusbox.innerHTML = ` 27 | Running Nibbler in a normal browser doesn't work. For the full app, see the 28 | Releases section of the repo.

29 | 30 | It has also been observed not to work if your path contains a % character.`; 31 | } 32 | 33 | // Requires......................................................... 34 | 35 | const background = require("./modules/background"); 36 | const child_process = require("child_process"); 37 | const clipboard = require("electron").clipboard; 38 | const config_io = require("./modules/config_io"); 39 | const custom_uci = require("./modules/custom_uci"); 40 | const engineconfig_io = require("./modules/engineconfig_io"); 41 | const fs = require("fs"); 42 | const images = require("./modules/images"); 43 | const ipcRenderer = require("electron").ipcRenderer; 44 | const messages = require("./modules/messages"); 45 | const path = require("path"); 46 | const querystring = require("querystring"); 47 | const readline = require("readline"); 48 | const stringify = require("./modules/stringify"); 49 | const translate = require("./modules/translate"); 50 | const util = require("util"); 51 | 52 | // Prior to v32, given a file object from an event (e.g. from dragging the file onto the window) 53 | // we could simply access its path, but afterwards we need to use a helper function... 54 | 55 | let webUtils = require("electron").webUtils; 56 | const get_path_for_file = (webUtils && webUtils.getPathForFile) ? webUtils.getPathForFile : file => file.path; 57 | 58 | // Globals.......................................................... 59 | 60 | const boardctx = canvas.getContext("2d"); 61 | const graphctx = graph.getContext("2d"); 62 | const decoder = new util.TextDecoder("utf8"); // https://github.com/electron/electron/issues/18733 63 | 64 | let [load_err1, config] = config_io.load(); 65 | let [load_err2, engineconfig] = engineconfig_io.load(); 66 | 67 | translate.register_startup_language(config.language); 68 | 69 | let next_node_id = 1; 70 | let live_nodes = Object.create(null); 71 | 72 | // Replace the renderer's built-in alert().......................... 73 | 74 | let alert = (msg) => { 75 | ipcRenderer.send("alert", stringify(msg)); 76 | }; 77 | 78 | // Get the images loading........................................... 79 | 80 | if (images.validate_folder(config.override_piece_directory)) { 81 | images.load_from(config.override_piece_directory); 82 | } else { 83 | images.load_from(path.join(__dirname, "pieces")); 84 | } 85 | 86 | // Standard options, for either type of engine...................... 87 | // Note that UCI_Chess960 is handled specially by engine.js 88 | 89 | const forced_lc0_options = { // These are sent without checking if they are known by the engine, so it doesn't matter 90 | "LogLiveStats": true, // if Leela is hiding them. Nevertheless, the user can still override them in engines.json. 91 | "MoveOverheadMs": 0, 92 | "MultiPV": 500, 93 | "ScoreType": "WDL_mu", 94 | "SmartPruningFactor": 0, 95 | "UCI_ShowWDL": true, 96 | "VerboseMoveStats": true, 97 | }; 98 | 99 | const standard_lc0_options = { // These are only sent if known by the engine. 100 | "ContemptMode": "white_side_analysis", 101 | "Contempt": 0, 102 | "WDLCalibrationElo": 0, 103 | "WDLEvalObjectivity": 0, 104 | }; 105 | 106 | const forced_ab_options = {}; 107 | 108 | const standard_ab_options = { 109 | "Contempt": 0, 110 | "Move Overhead": 0, 111 | "UCI_ShowWDL": true, 112 | }; 113 | 114 | // Yeah this seemed a good idea at the time......................... 115 | 116 | const limit_options = [ 117 | 1, 2, 5, 10, 20, 50, 100, 125, 160, 200, 250, 320, 400, 500, 640, 800, 118 | 1000, 1250, 1600, 2000, 2500, 3200, 4000, 5000, 6400, 8000, 10000, 12500, 119 | 16000, 20000, 25000, 32000, 40000, 50000, 64000, 80000, 100000, 125000, 120 | 160000, 200000, 250000, 320000, 400000, 500000, 640000, 800000, 1000000, 121 | 1250000, 1600000, 2000000, 2500000, 3200000, 4000000, 5000000, 6400000, 122 | 8000000, 10000000, 12500000, 16000000, 20000000, 25000000, 32000000, 123 | 40000000, 50000000, 64000000, 80000000, 100000000, 125000000, 160000000, 124 | 200000000, 250000000, 320000000, 400000000, 500000000, 640000000, 125 | 800000000, 1000000000 126 | ]; 127 | 128 | limit_options.sort((a, b) => a - b); 129 | -------------------------------------------------------------------------------- /files/src/renderer/30_point.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function Point(a, b) { 4 | 5 | // Each Point is represented by a single object so that naive equality checking works, i.e. 6 | // Point(x, y) === Point(x, y) should be true. Since object comparisons in JS will be false 7 | // unless they are the same object, we do the following... 8 | // 9 | // Returns null on invalid input, therefore the caller should take care to ensure that the 10 | // value is not null before accessing .x or .y or .s! 11 | 12 | if (Point.xy_lookup === undefined) { 13 | Point.xy_lookup = New2DArray(8, 8, null); 14 | for (let x = 0; x < 8; x++) { 15 | for (let y = 0; y < 8; y++) { 16 | let s = S(x, y); 17 | let point = Object.freeze({x, y, s}); 18 | Point.xy_lookup[x][y] = point; 19 | } 20 | } 21 | } 22 | 23 | // Point("a8") or Point(0, 0) are both valid. 24 | 25 | if (b === undefined) { 26 | [a, b] = XY(a); // Possibly [-1, -1] if invalid 27 | } 28 | 29 | let col = Point.xy_lookup[a]; 30 | if (col === undefined) return null; 31 | 32 | let ret = col[b]; 33 | if (ret === undefined) return null; 34 | 35 | return ret; 36 | } 37 | 38 | function PointsBetween(a, b) { 39 | 40 | // Given points a and b, return a list of points between the two, inclusive. 41 | 42 | if (!a && !b) return []; 43 | if (!a) return [b]; 44 | if (!b) return [a]; 45 | 46 | if (a === b) { 47 | return [a]; 48 | } 49 | 50 | let ok = false; 51 | 52 | if (a.x === b.x) { 53 | ok = true; 54 | } 55 | 56 | if (a.y === b.y) { 57 | ok = true; 58 | } 59 | 60 | if (Math.abs(a.x - b.x) === Math.abs(a.y - b.y)) { 61 | ok = true; 62 | } 63 | 64 | if (ok === false) { 65 | return [a, b]; 66 | } 67 | 68 | let stepx = Sign(b.x - a.x); 69 | let stepy = Sign(b.y - a.y); 70 | 71 | let x = a.x; 72 | let y = a.y; 73 | 74 | let ret = []; 75 | 76 | while (1) { 77 | ret.push(Point(x, y)); 78 | if (x === b.x && y === b.y) { 79 | return ret; 80 | } 81 | x += stepx; 82 | y += stepy; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /files/src/renderer/31_sliders.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // This makes an object storing "sliders" for every piece except K and k which are handled 4 | // differently. A slider is a list of vectors, which are distances from the origin. 5 | 6 | function generate_movegen_sliders() { 7 | 8 | let invert = n => n === 0 ? 0 : -n; // Flip sign without introducing -0 9 | let rotate = xy => [invert(xy[1]), xy[0]]; // Rotate a single vector of form [x,y] 10 | let flip = xy => [invert(xy[0]), xy[1]]; // Flip a single vector, horizontally 11 | 12 | let ret = Object.create(null); 13 | 14 | // For each of B, R, N, make an initial slider and place it in a new list as item 0... 15 | ret.B = [[[1,1], [2,2], [3,3], [4,4], [5,5], [6,6], [7,7]]]; 16 | ret.R = [[[1,0], [2,0], [3,0], [4,0], [5,0], [6,0], [7,0]]]; 17 | ret.N = [[[1,2]]]; 18 | 19 | // Add 3 rotations for each... 20 | for (let n = 0; n < 3; n++) { 21 | ret.B.push(ret.B[n].map(rotate)); 22 | ret.R.push(ret.R[n].map(rotate)); 23 | ret.N.push(ret.N[n].map(rotate)); 24 | } 25 | 26 | // Add the knight mirrors (knights have 8 sliders of length 1)... 27 | ret.N = ret.N.concat(ret.N.map(slider => slider.map(flip))); 28 | 29 | // Make the queen from the rook and bishop... 30 | ret.Q = ret.B.concat(ret.R); 31 | 32 | // The black lowercase versions can point to the same objects... 33 | for (let key of Object.keys(ret)) { 34 | ret[key.toLowerCase()] = ret[key]; 35 | } 36 | 37 | // Make the pawns... 38 | ret.P = [[[0,-1], [0,-2]], [[-1,-1]], [[1,-1]]]; 39 | ret.p = [[[0,1], [0,2]], [[-1,1]], [[1,1]]]; 40 | 41 | return ret; 42 | } 43 | 44 | let movegen_sliders = generate_movegen_sliders(); 45 | -------------------------------------------------------------------------------- /files/src/renderer/41_fen.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function LoadFEN(fen) { 4 | 5 | if (fen.length > 200) { 6 | throw "Invalid FEN - size"; 7 | } 8 | 9 | let ret = NewPosition(); 10 | 11 | fen = ReplaceAll(fen, "\t", " "); 12 | fen = ReplaceAll(fen, "\n", " "); 13 | fen = ReplaceAll(fen, "\r", " "); 14 | 15 | let tokens = fen.split(" ").filter(z => z !== ""); 16 | 17 | if (tokens.length === 1) tokens.push("w"); 18 | if (tokens.length === 2) tokens.push("-"); 19 | if (tokens.length === 3) tokens.push("-"); 20 | if (tokens.length === 4) tokens.push("0"); 21 | if (tokens.length === 5) tokens.push("1"); 22 | 23 | if (tokens.length !== 6) { 24 | throw "Invalid FEN - token count"; 25 | } 26 | 27 | if (tokens[0].endsWith("/")) { // Some FEN writer does this 28 | tokens[0] = tokens[0].slice(0, -1); 29 | } 30 | 31 | let rows = tokens[0].split("/"); 32 | 33 | if (rows.length > 8) { 34 | throw "Invalid FEN - board row count"; 35 | } 36 | 37 | for (let y = 0; y < rows.length; y++) { 38 | 39 | let x = 0; 40 | 41 | for (let c of rows[y]) { 42 | 43 | if (x > 7) { 44 | throw "Invalid FEN - row length"; 45 | } 46 | 47 | if (["1", "2", "3", "4", "5", "6", "7", "8"].includes(c)) { 48 | x += parseInt(c, 10); 49 | continue; 50 | } 51 | 52 | if (["K", "k", "Q", "q", "R", "r", "B", "b", "N", "n", "P", "p"].includes(c)) { 53 | ret.state[x][y] = c; 54 | x++; 55 | continue; 56 | } 57 | 58 | throw "Invalid FEN - unknown piece"; 59 | } 60 | } 61 | 62 | tokens[1] = tokens[1].toLowerCase(); 63 | if (tokens[1] !== "w" && tokens[1] !== "b") { 64 | throw "Invalid FEN - active player"; 65 | } 66 | ret.active = tokens[1]; 67 | 68 | ret.halfmove = parseInt(tokens[4], 10); 69 | if (Number.isNaN(ret.halfmove)) { 70 | throw "Invalid FEN - halfmoves"; 71 | } 72 | 73 | ret.fullmove = parseInt(tokens[5], 10); 74 | if (Number.isNaN(ret.fullmove)) { 75 | throw "Invalid FEN - fullmoves"; 76 | } 77 | 78 | // Some more validity checks... 79 | 80 | let white_kings = 0; 81 | let black_kings = 0; 82 | 83 | for (let x = 0; x < 8; x++) { 84 | for (let y = 0; y < 8; y++) { 85 | if (ret.state[x][y] === "K") white_kings++; 86 | if (ret.state[x][y] === "k") black_kings++; 87 | } 88 | } 89 | 90 | if (white_kings !== 1 || black_kings !== 1) { 91 | throw "Invalid FEN - number of kings"; 92 | } 93 | 94 | for (let x = 0; x < 8; x++) { 95 | for (let y of [0, 7]) { 96 | if (ret.state[x][y] === "P" || ret.state[x][y] === "p") { 97 | throw "Invalid FEN - pawn position"; 98 | } 99 | } 100 | } 101 | 102 | let opponent_king_char = ret.active === "w" ? "k" : "K"; 103 | let opponent_king_square = ret.find(opponent_king_char)[0]; 104 | 105 | if (ret.attacked(opponent_king_square, ret.colour(opponent_king_square))) { 106 | throw "Invalid FEN - non-mover's king in check"; 107 | } 108 | 109 | // Some hard things. Do these in the right order! 110 | 111 | ret.castling = CastlingRights(ret, tokens[2]); 112 | ret.enpassant = EnPassantSquare(ret, tokens[3]); // Requires ret.active to be correct. 113 | ret.normalchess = IsNormalChessPosition(ret); // Requires ret.castling to be correct. 114 | 115 | return ret; 116 | } 117 | 118 | 119 | function CastlingRights(board, s) { // s is the castling string from a FEN 120 | 121 | let dict = Object.create(null); // Will contain keys like "A" to "H" and "a" to "h" 122 | 123 | // WHITE 124 | 125 | let wk_location = board.find("K", 0, 7, 7, 7)[0]; // Will be undefined if not on back rank. 126 | 127 | if (wk_location) { 128 | 129 | for (let ch of s) { 130 | if (["A", "B", "C", "D", "E", "F", "G", "H"].includes(ch)) { 131 | let point = Point(ch.toLowerCase() + "1"); 132 | if (board.piece(point) === "R") { 133 | dict[ch] = true; 134 | } 135 | } 136 | if (ch === "Q") { 137 | if (board.state[0][7] === "R") { // Compatibility with regular Chess FEN. 138 | dict.A = true; 139 | } else { 140 | let left_rooks = board.find("R", 0, 7, wk_location.x, 7); 141 | for (let rook of left_rooks) { 142 | dict[rook.s[0].toUpperCase()] = true; 143 | } 144 | } 145 | } 146 | if (ch === "K") { 147 | if (board.state[7][7] === "R") { // Compatibility with regular Chess FEN. 148 | dict.H = true; 149 | } else { 150 | let right_rooks = board.find("R", wk_location.x, 7, 7, 7); 151 | for (let rook of right_rooks) { 152 | dict[rook.s[0].toUpperCase()] = true; 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | // BLACK 160 | 161 | let bk_location = board.find("k", 0, 0, 7, 0)[0]; 162 | 163 | if (bk_location) { 164 | 165 | for (let ch of s) { 166 | if (["a", "b", "c", "d", "e", "f", "g", "h"].includes(ch)) { 167 | let point = Point(ch + "8"); 168 | if (board.piece(point) === "r") { 169 | dict[ch] = true; 170 | } 171 | } 172 | if (ch === "q") { 173 | if (board.state[0][0] === "r") { // Compatibility with regular Chess FEN. 174 | dict.a = true; 175 | } else { 176 | let left_rooks = board.find("r", 0, 0, bk_location.x, 0); 177 | for (let rook of left_rooks) { 178 | dict[rook.s[0]] = true; 179 | } 180 | } 181 | } 182 | if (ch === "k") { 183 | if (board.state[7][0] === "r") { // Compatibility with regular Chess FEN. 184 | dict.h = true; 185 | } else { 186 | let right_rooks = board.find("r", bk_location.x, 0, 7, 0); 187 | for (let rook of right_rooks) { 188 | dict[rook.s[0]] = true; 189 | } 190 | } 191 | } 192 | } 193 | } 194 | 195 | let ret = ""; 196 | 197 | for (let ch of "ABCDEFGHabcdefgh") { 198 | if (dict[ch]) { 199 | ret += ch; 200 | } 201 | } 202 | 203 | return ret; 204 | 205 | // FIXME: check at most 1 castling possibility on left and right of each king? 206 | // At the moment we support more arbitrary castling rights, maybe that's OK. 207 | } 208 | 209 | 210 | function EnPassantSquare(board, s) { // board.active must be correct. s is the en-passant string from a FEN. 211 | 212 | // Suffers from the same subtleties as the enpassant setter in position.move(), see there for comments. 213 | 214 | let p = Point(s.toLowerCase()); 215 | 216 | if (!p) { 217 | return null; 218 | } 219 | 220 | if (board.active === "w") { 221 | if (p.y !== 2) { 222 | return null; 223 | } 224 | // Check the takeable pawn exists... 225 | if (board.piece(Point(p.x, 3)) !== "p") { 226 | return null; 227 | } 228 | // Check the capture square is actually empty... 229 | if (board.piece(Point(p.x, 2)) !== "") { 230 | return null; 231 | } 232 | // Check potential capturer exists... 233 | if (board.piece(Point(p.x - 1, 3)) !== "P" && board.piece(Point(p.x + 1, 3)) !== "P") { 234 | return null; 235 | } 236 | return p; 237 | } 238 | 239 | if (board.active === "b") { 240 | if (p.y !== 5) { 241 | return null; 242 | } 243 | // Check the takeable pawn exists... 244 | if (board.piece(Point(p.x, 4)) !== "P") { 245 | return null; 246 | } 247 | // Check the capture square is actually empty... 248 | if (board.piece(Point(p.x, 5)) !== "") { 249 | return null; 250 | } 251 | // Check potential capturer exists... 252 | if (board.piece(Point(p.x - 1, 4)) !== "p" && board.piece(Point(p.x + 1, 4)) !== "p") { 253 | return null; 254 | } 255 | return p; 256 | } 257 | 258 | return null; 259 | } 260 | 261 | 262 | function IsNormalChessPosition(board) { 263 | 264 | for (let ch of "bcdefgBCDEFG") { 265 | if (board.castling.includes(ch)) { 266 | return false; 267 | } 268 | } 269 | 270 | if (board.castling.includes("A") || board.castling.includes("H")) { 271 | if (board.state[4][7] !== "K") { 272 | return false; 273 | } 274 | } 275 | 276 | if (board.castling.includes("a") || board.castling.includes("h")) { 277 | if (board.state[4][0] !== "k") { 278 | return false; 279 | } 280 | } 281 | 282 | // So it can be considered a normal Chess position. 283 | 284 | return true; 285 | } 286 | -------------------------------------------------------------------------------- /files/src/renderer/42_perft.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* Perft notes: 4 | 5 | The correct perft value for a position and depth is the number of leaf nodes at 6 | that depth (or equivalently, the number of legal move sequences of that length). 7 | 8 | Some important points: 9 | 10 | - Rules about "Triple Repetition" and "Insufficient Material" are ignored. 11 | - Terminal nodes (mates) at a shallower depth are not counted. 12 | - But they are counted if they are at the correct depth. 13 | 14 | In Stockfish: 15 | 16 | setoption name UCI_Chess960 value true 17 | position fen 18 | go perft 4 19 | 20 | */ 21 | 22 | function perft(pos, depth, print_moves) { 23 | let moves = pos.movegen(); 24 | if (depth === 1) { 25 | return moves.length; 26 | } else { 27 | let count = 0; 28 | for (let mv of moves) { 29 | let val = perft(pos.move(mv), depth - 1, false); 30 | if (print_moves) { 31 | perft_print_move(pos, mv, val); 32 | } 33 | count += val; 34 | } 35 | return count; 36 | } 37 | } 38 | 39 | function perft_print_move(pos, mv, val) { 40 | let nice = pos.nice_string(mv); 41 | console.log(`${mv + (mv.length === 4 ? " " : "")} ${nice + " ".repeat(7 - nice.length)}`, val); 42 | } 43 | 44 | // ------------------------------------------------------------------------------------------------------------------- 45 | 46 | let perft_known_values = { 47 | "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1": [0, 14, 191, 2812, 43238, 674624], 48 | "1nr1nk1r/1b5B/p1p1qp2/b2pp1pP/3P2P1/P3P2N/1Pp2P2/BNR2KQR w CHch g6 0 1": [0, 28, 964, 27838, 992438, 30218648], 49 | "Qr3knr/P1bp1p1p/2pn1q2/4p3/2PP2pB/1p1N1bP1/BP2PP1P/1R3KNR w BHbh - 0 1": [0, 31, 1122, 34613, 1253934, 40393041], 50 | "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1": [0, 48, 2039, 97862, 4085603, 193690690], 51 | "r3k2r/1P1pp1P1/8/2P2P2/2p2p2/8/1p1PP1p1/R3K2R w KQkq - 0 1": [0, 43, 1286, 39109, 1134150, 33406158], 52 | }; 53 | 54 | function Perft(fen, depth) { 55 | if (!fen || !depth) throw "Need FEN and depth"; 56 | let starttime = performance.now(); 57 | let board = LoadFEN(fen); 58 | let val = perft(board, depth, true); 59 | console.log(`Total.......... ${val} (${((performance.now() - starttime) / 1000).toFixed(1)} seconds)`); 60 | if (perft_known_values[fen] && perft_known_values[fen][depth]) { 61 | if (perft_known_values[fen][depth] === val) { 62 | console.log("Known good result"); 63 | } else { 64 | console.log(`Known BAD result -- expected ${perft_known_values[fen][depth]}`); 65 | } 66 | } 67 | return val; 68 | } 69 | 70 | function PerftFileTest(filename, depth) { 71 | 72 | if (!filename || !depth) throw "Need filename and depth"; 73 | 74 | let contents = fs.readFileSync(filename).toString(); 75 | let lines = contents.split("\n").map(z => z.trim()).filter(z => z !== ""); 76 | 77 | for (let n = 0; n < lines.length; n++) { 78 | 79 | let blobs = lines[n].split(";"); 80 | let result = perft(LoadFEN(blobs[0]), depth, false); 81 | 82 | if (lines[n].includes(result.toString())) { 83 | console.log(`ok -- ${n + 1} / ${lines.length} -- ${blobs[0]}`); 84 | } else { 85 | console.log(`FAILED -- ${n + 1} / ${lines.length} -- ${blobs[0]}`); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /files/src/renderer/43_chess960.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function c960_arrangement(n) { 4 | 5 | // Given n, generate a string like "RNBQKBNR". 6 | // AFAIK, matches the scheme of Reinhard Scharnagl. 7 | 8 | if (n < 0) { 9 | n *= -1; 10 | } 11 | n = Math.floor(n) % 960; 12 | 13 | let pieces = [".", ".", ".", ".", ".", ".", ".", "."]; 14 | 15 | // Helper function to place a piece at an "index", 16 | // but considering only empty spots. 17 | 18 | let insert = (i, piece) => { 19 | for (let n = 0; n < 8; n++) { 20 | if (pieces[n] === "." && --i < 0) { // Careful! Remember short-circuit rules etc. 21 | pieces[n] = piece; 22 | return; 23 | } 24 | } 25 | }; 26 | 27 | // Place bishops in final positions... 28 | 29 | pieces[(Math.floor(n / 4) % 4) * 2] = "B"; 30 | pieces[(n % 4) * 2 + 1] = "B"; 31 | 32 | // Place queen in one of 6 remaining spots... 33 | 34 | let qi = Math.floor(n / 16) % 6; 35 | insert(qi, "Q"); 36 | 37 | // Knights are arranged in one of 10 possible configurations 38 | // (considering only the remaining spots)... 39 | 40 | let ni1 = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3][Math.floor(n / 96)]; 41 | let ni2 = [1, 2, 3, 4, 2, 3, 4, 3, 4, 4][Math.floor(n / 96)]; 42 | 43 | insert(ni2, "N"); // Must be done in this order, 44 | insert(ni1, "N"); // works because ni2 > ni1 45 | 46 | // Place left rook, king, right rook in first available spots... 47 | 48 | insert(0, "R"); 49 | insert(0, "K"); 50 | insert(0, "R"); 51 | 52 | return pieces.join(""); 53 | } 54 | 55 | function c960_fen(n) { 56 | 57 | // Given n, produce a full FEN. 58 | 59 | let pieces = c960_arrangement(n); // The uppercase version. 60 | 61 | let s = `${pieces.toLowerCase()}/pppppppp/8/8/8/8/PPPPPPPP/${pieces}`; 62 | 63 | let castling_rights = ""; 64 | 65 | for (let i = 0; i < 8; i++) { 66 | if (pieces[i] === "R") { 67 | castling_rights += String.fromCharCode(i + 65); 68 | } 69 | } 70 | 71 | castling_rights += castling_rights.toLowerCase(); 72 | 73 | return `${s} w ${castling_rights} - 0 1`; 74 | } 75 | -------------------------------------------------------------------------------- /files/src/renderer/50_table.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // The table object stores info from the engine about a game-tree (PGN) node. 4 | 5 | function NewTable() { 6 | let table = Object.create(table_prototype); 7 | table.clear(); 8 | return table; 9 | } 10 | 11 | const table_prototype = { 12 | 13 | clear: function() { 14 | this.moveinfo = Object.create(null); // move --> info 15 | this.version = 0; // Incremented on any change 16 | this.nodes = 0; // Stat sent by engine 17 | this.nps = 0; // Stat sent by engine 18 | this.tbhits = 0; // Stat sent by engine 19 | this.time = 0; // Stat sent by engine 20 | this.limit = null; // The limit of the last search that updated this. 21 | this.terminal = null; // null = unknown, "" = not terminal, "Non-empty string" = terminal reason 22 | this.graph_y = null; // Used by grapher only, value from White's POV between 0 and 1 23 | this.graph_y_version = 0; // Which version (above) was used to generate the graph_y value 24 | this.already_autopopulated = false; 25 | }, 26 | 27 | get_graph_y: function() { 28 | 29 | // Naphthalin's scheme: based on centipawns. 30 | 31 | if (this.graph_y_version === this.version) { 32 | return this.graph_y; 33 | } else { 34 | let info = SortedMoveInfoFromTable(this)[0]; 35 | if (info && !info.__ghost && info.__touched && (this.nodes > 1 || this.limit === 1)) { 36 | let cp = info.cp; 37 | if (info.board.active === "b") { 38 | cp *= -1; 39 | } 40 | this.graph_y = 1 / (1 + Math.pow(0.5, cp / 100)); 41 | } else { 42 | this.graph_y = null; 43 | } 44 | this.graph_y_version = this.version; 45 | return this.graph_y; 46 | } 47 | }, 48 | 49 | set_terminal_info: function(reason, ev) { // ev is ignored if reason is "" (i.e. not a terminal position) 50 | if (reason) { 51 | this.terminal = reason; 52 | this.graph_y = ev; 53 | this.graph_y_version = this.version; 54 | } else { 55 | this.terminal = ""; 56 | } 57 | }, 58 | 59 | autopopulate: function(node) { 60 | 61 | if (!node) { 62 | throw "autopopulate() requires node argument"; 63 | } 64 | 65 | if (this.already_autopopulated) { 66 | return; 67 | } 68 | 69 | if (node.destroyed) { 70 | return; 71 | } 72 | 73 | let moves = node.board.movegen(); 74 | 75 | for (let move of moves) { 76 | if (node.table.moveinfo[move] === undefined) { 77 | node.table.moveinfo[move] = NewInfo(node.board, move); 78 | } 79 | } 80 | 81 | this.already_autopopulated = true; 82 | } 83 | }; 84 | 85 | // -------------------------------------------------------------------------------------------- 86 | // The info object stores info received from the engine about a move. The actual updating of 87 | // the object takes place in info.js and the ih.receive() method there. 88 | 89 | function NewInfo(board, move) { 90 | 91 | let info = Object.create(info_prototype); 92 | 93 | info.board = board; 94 | info.move = move; 95 | info.__ghost = false; // If not false, this is temporary inferred info. 96 | info.__touched = false; // Has this ever actually been updated? 97 | info.leelaish = false; // Whether the most recent update to this info was from an engine considered Leelaish. 98 | info.pv = [move]; // Validated as a legal sequence upon reception. 99 | info.cycle = 0; // How many "go" commands Nibbler has emitted. 100 | info.subcycle = 0; // How many "blocks" of info we have seen (delineated by multipv 1 info). 101 | 102 | info.nice_pv_cache = [board.nice_string(move)]; 103 | 104 | info.clear_stats(); 105 | return info; 106 | } 107 | 108 | const info_prototype = { 109 | 110 | // I'm not sure I've been conscientious everywhere in the code about checking whether these things are 111 | // of the right type, so for that reason most are set to some neutralish value by default. 112 | // 113 | // Exceptions: m, v, wdl (and note that all of these can be set to null by info.js) 114 | 115 | clear_stats: function() { 116 | this.cp = 0; 117 | this.depth = 0; 118 | this.m = null; 119 | this.mate = 0; // 0 can be the "not present" value. 120 | this.multipv = 1; 121 | this.n = 0; 122 | this.p = 0; // Note P is received and stored as a percent, e.g. 31.76 is a reasonable P. 123 | this.q = 0; 124 | this.s = 1; // Known as Q+U before Lc0 v0.25-rc2 125 | this.seldepth = 0; 126 | this.u = 1; 127 | this.uci_nodes = 0; // The number of nodes reported by the UCI info lines (i.e. for the whole position). 128 | this.v = null; 129 | this.vms_order = 0; // VerboseMoveStats order, 0 means not present, 1 is the worst, higher is better. 130 | this.wdl = null; // Either null or a length 3 array of ints. 131 | }, 132 | 133 | set_pv: function(pv) { 134 | this.pv = Array.from(pv); 135 | this.nice_pv_cache = null; 136 | }, 137 | 138 | nice_pv: function() { 139 | 140 | // Human readable moves. 141 | 142 | if (this.nice_pv_cache) { 143 | return Array.from(this.nice_pv_cache); 144 | } 145 | 146 | let tmp_board = this.board; 147 | 148 | if (!this.pv || this.pv.length === 0) { // Should be impossible. 149 | this.pv = [this.move]; 150 | } 151 | 152 | let ret = []; 153 | 154 | for (let move of this.pv) { 155 | 156 | // if (tmp_board.illegal(move)) break; // Should be impossible as of 1.8.4: PVs are validated upon reception, and the only other 157 | // way they can get changed is by maybe_infer_info(), which hopefully is sound. 158 | ret.push(tmp_board.nice_string(move)); 159 | tmp_board = tmp_board.move(move); 160 | } 161 | 162 | this.nice_pv_cache = ret; 163 | return Array.from(this.nice_pv_cache); 164 | }, 165 | 166 | value: function() { 167 | return Value(this.q); // Rescaled to 0..1 168 | }, 169 | 170 | value_string: function(dp, pov) { 171 | if (!this.__touched || typeof this.q !== "number") { 172 | return "?"; 173 | } 174 | if (this.leelaish && this.n === 0) { 175 | return "?"; 176 | } 177 | let val = this.value(); 178 | if ((pov === "w" && this.board.active === "b") || (pov === "b" && this.board.active === "w")) { 179 | val = 1 - val; 180 | } 181 | return (val * 100).toFixed(dp); 182 | }, 183 | 184 | cp_string: function(pov) { 185 | if (!this.__touched || typeof this.cp !== "number") { 186 | return "?"; 187 | } 188 | if (this.leelaish && this.n === 0) { 189 | return "?"; 190 | } 191 | let cp = this.cp; 192 | if ((pov === "w" && this.board.active === "b") || (pov === "b" && this.board.active === "w")) { 193 | cp = 0 - cp; 194 | } 195 | let ret = (cp / 100).toFixed(2); 196 | if (cp > 0) { 197 | ret = "+" + ret; 198 | } 199 | return ret; 200 | }, 201 | 202 | mate_string: function(pov) { 203 | if (typeof this.mate !== "number" || this.mate === 0) { 204 | return "?"; 205 | } 206 | let mate = this.mate; 207 | if ((pov === "w" && this.board.active === "b") || (pov === "b" && this.board.active === "w")) { 208 | mate = 0 - mate; 209 | } 210 | if (mate < 0) { 211 | return `(-M${0 - mate})`; 212 | } else { 213 | return `(+M${mate})`; 214 | } 215 | }, 216 | 217 | wdl_string: function(pov) { 218 | if (Array.isArray(this.wdl) === false || this.wdl.length !== 3) { 219 | return "?"; 220 | } 221 | if ((pov === "w" && this.board.active === "b") || (pov === "b" && this.board.active === "w")) { 222 | return `${this.wdl[2]} ${this.wdl[1]} ${this.wdl[0]}`; 223 | } else { 224 | return `${this.wdl[0]} ${this.wdl[1]} ${this.wdl[2]}`; 225 | } 226 | }, 227 | 228 | stats_list: function(opts, total_nodes) { // We pass total_nodes rather than use this.uci_nodes which can be obsolete (e.g. due to searchmoves) 229 | 230 | if (this.__ghost) { 231 | return ["Inferred"]; 232 | } 233 | 234 | let ret = []; 235 | 236 | if (opts.ev) { 237 | ret.push(`EV: ${this.value_string(1, opts.ev_pov)}%`); 238 | } 239 | 240 | if (opts.cp) { 241 | ret.push(`CP: ${this.cp_string(opts.cp_pov)}`); 242 | } 243 | 244 | // N is fairly complicated... 245 | 246 | if (this.leelaish) { 247 | 248 | if (typeof this.n === "number" && total_nodes) { // i.e. total_nodes is not zero or undefined 249 | 250 | let n_string = ""; 251 | 252 | if (opts.n) { 253 | n_string += ` N: ${(100 * this.n / total_nodes).toFixed(2)}%`; 254 | } 255 | 256 | if (opts.n_abs) { 257 | if (opts.n) { 258 | n_string += ` [${NString(this.n)}]`; 259 | } else { 260 | n_string += ` N: ${NString(this.n)}`; 261 | } 262 | } 263 | 264 | if (opts.of_n) { 265 | n_string += ` of ${NString(total_nodes)}`; 266 | } 267 | 268 | if (n_string !== "") { 269 | ret.push(n_string.trim()); 270 | } 271 | 272 | } else { 273 | 274 | if (opts.n || opts.n_abs || opts.of_n) { 275 | ret.push("N: ?"); 276 | } 277 | 278 | } 279 | } 280 | 281 | // Everything else... 282 | 283 | if (!this.leelaish) { 284 | if (opts.depth) { 285 | if (typeof this.depth === "number" && this.depth > 0) { 286 | ret.push(`Depth: ${this.depth}`); 287 | } else { 288 | ret.push(`Depth: 0`); 289 | } 290 | } 291 | } 292 | 293 | if (this.leelaish) { 294 | if (opts.p) { 295 | if (typeof this.p === "number" && this.p > 0) { 296 | ret.push(`P: ${this.p}%`); 297 | } else { 298 | ret.push(`P: ?`); 299 | } 300 | } 301 | if (opts.v) { 302 | if (typeof this.v === "number") { 303 | ret.push(`V: ${this.v.toFixed(3)}`); 304 | } else { 305 | ret.push(`V: ?`); 306 | } 307 | } 308 | } 309 | 310 | if (opts.q) { 311 | if (typeof this.q === "number") { 312 | ret.push(`Q: ${this.q.toFixed(3)}`); 313 | } else { 314 | ret.push(`Q: ?`); 315 | } 316 | } 317 | 318 | if (this.leelaish) { 319 | if (opts.u) { 320 | if (typeof this.u === "number" && this.n > 0) { // Checking n is correct. 321 | ret.push(`U: ${this.u.toFixed(3)}`); 322 | } else { 323 | ret.push(`U: ?`); 324 | } 325 | } 326 | if (opts.s) { 327 | if (typeof this.s === "number" && this.n > 0) { // Checking n is correct. 328 | ret.push(`S: ${this.s.toFixed(5)}`); 329 | } else { 330 | ret.push(`S: ?`); 331 | } 332 | } 333 | if (opts.m) { 334 | if (typeof this.m === "number") { 335 | if (this.m > 0) { 336 | ret.push(`M: ${this.m.toFixed(1)}`); 337 | } else { 338 | ret.push(`M: 0`); 339 | } 340 | } else { 341 | ret.push(`M: ?`); 342 | } 343 | } 344 | } 345 | 346 | if (opts.wdl) { 347 | ret.push(`WDL: ${this.wdl_string(opts.wdl_pov)}`); 348 | } 349 | 350 | return ret; 351 | } 352 | }; 353 | -------------------------------------------------------------------------------- /files/src/renderer/52_sorted_moves.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function SortedMoveInfo(node) { 4 | 5 | if (!node || node.destroyed) { 6 | return []; 7 | } 8 | 9 | return SortedMoveInfoFromTable(node.table); 10 | } 11 | 12 | function SortedMoveInfoFromTable(table) { 13 | 14 | // There are a lot of subtleties around sorting the moves... 15 | // 16 | // - We want to allow other engines than Lc0. 17 | // - We want to work with low MultiPV values. 18 | // - Old and stale data can be left in our cache if MultiPV is low. 19 | // - We want to work with searchmoves, which is bound to leave stale info in the table. 20 | // - We can try and track the age of the data by various means, but these are fallible. 21 | 22 | let info_list = []; 23 | let latest_cycle = 0; 24 | let latest_subcycle = 0; 25 | 26 | for (let o of Object.values(table.moveinfo)) { 27 | info_list.push(o); 28 | if (o.cycle > latest_cycle) latest_cycle = o.cycle; 29 | if (o.subcycle > latest_subcycle) latest_subcycle = o.subcycle; 30 | } 31 | 32 | // It's important that the sort be transitive. I believe it is. 33 | 34 | info_list.sort((a, b) => { 35 | 36 | const a_is_best = -1; // return -1 to sort a to the left 37 | const b_is_best = 1; // return 1 to sort a to the right 38 | 39 | // Info that hasn't been touched must be worse... 40 | 41 | if (a.__touched && !b.__touched) return a_is_best; 42 | if (!a.__touched && b.__touched) return b_is_best; 43 | 44 | // Always prefer info from the current "go" specifically. 45 | // As well as being correct generally, it also moves searchmoves to the top. 46 | 47 | if (a.cycle === latest_cycle && b.cycle !== latest_cycle) return a_is_best; 48 | if (a.cycle !== latest_cycle && b.cycle === latest_cycle) return b_is_best; 49 | 50 | // Prefer info from the current "block" of info specifically. 51 | 52 | if (a.subcycle === latest_subcycle && b.subcycle !== latest_subcycle) return a_is_best; 53 | if (a.subcycle !== latest_subcycle && b.subcycle === latest_subcycle) return b_is_best; 54 | 55 | // If one info is leelaish and the other isn't, that can only mean that the A/B 56 | // engine is the one that ran last (since Lc0 will cause all info to become 57 | // leelaish), therefore any moves the A/B engine has touched must be "better". 58 | 59 | if (!a.leelaish && b.leelaish) return a_is_best; 60 | if (a.leelaish && !b.leelaish) return b_is_best; 61 | 62 | // ----------------------------------- LEELA AND LEELA-LIKE ENGINES ----------------------------------- // 63 | 64 | if (a.leelaish && b.leelaish) { 65 | 66 | // Mate - positive good, negative bad. 67 | // Note our info struct uses 0 when not given. 68 | 69 | if (Sign(a.mate) !== Sign(b.mate)) { // negative is worst, 0 is neutral, positive is best 70 | if (a.mate > b.mate) return a_is_best; 71 | if (a.mate < b.mate) return b_is_best; 72 | } else { // lower (i.e. towards -Inf) is better regardless of who's mating 73 | if (a.mate < b.mate) return a_is_best; 74 | if (a.mate > b.mate) return b_is_best; 75 | } 76 | 77 | // Ordering by VerboseMoveStats (suggestion of Napthalin)... 78 | 79 | if (a.vms_order > b.vms_order) return a_is_best; 80 | if (a.vms_order < b.vms_order) return b_is_best; 81 | 82 | // Leela N score (node count) - higher is better (shouldn't be possible to get here now)... 83 | 84 | if (a.n > b.n) return a_is_best; 85 | if (a.n < b.n) return b_is_best; 86 | } 87 | 88 | // ---------------------------------------- ALPHA-BETA ENGINES ---------------------------------------- // 89 | 90 | if (a.leelaish === false && b.leelaish === false) { 91 | 92 | // Specifically within the latest subcycle, prefer lower multipv. I don't think this 93 | // breaks transitivity because the latest subcycle is always sorted left (see above). 94 | 95 | if (a.subcycle === latest_subcycle && b.subcycle === latest_subcycle) { 96 | if (a.multipv < b.multipv) return a_is_best; 97 | if (a.multipv > b.multipv) return b_is_best; 98 | } 99 | 100 | // Otherwise sort by depth. 101 | 102 | if (a.depth > b.depth) return a_is_best; 103 | if (a.depth < b.depth) return b_is_best; 104 | 105 | // Sort by CP if we somehow get here. 106 | 107 | if (a.cp > b.cp) return a_is_best; 108 | if (a.cp < b.cp) return b_is_best; 109 | } 110 | 111 | // Sort alphabetically... 112 | 113 | if (a.nice_pv_cache && b.nice_pv_cache) { 114 | if (a.nice_pv_cache[0] < b.nice_pv_cache[0]) return a_is_best; 115 | if (a.nice_pv_cache[0] > b.nice_pv_cache[0]) return b_is_best; 116 | } 117 | 118 | return 0; 119 | }); 120 | 121 | return info_list; 122 | } 123 | -------------------------------------------------------------------------------- /files/src/renderer/55_winrate_graph.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function NewGrapher() { 4 | 5 | let grapher = Object.create(null); 6 | 7 | grapher.dragging = false; // Used by the event handlers in start.js 8 | 9 | grapher.clear_graph = function() { 10 | 11 | let boundingrect = graph.getBoundingClientRect(); 12 | let width = window.innerWidth - boundingrect.left - 16; 13 | let height = boundingrect.bottom - boundingrect.top; 14 | 15 | // This clears the canvas... 16 | 17 | graph.width = width; 18 | graph.height = height; 19 | }; 20 | 21 | grapher.draw = function(node, force) { 22 | if (config.graph_height <= 0) { 23 | return; 24 | } 25 | this.draw_everything(node); 26 | }; 27 | 28 | grapher.draw_everything = function(node) { 29 | 30 | this.clear_graph(); 31 | let width = graph.width; // After the above. 32 | let height = graph.height; 33 | 34 | let eval_list = node.all_graph_values(); 35 | this.draw_horizontal_lines(width, height, [1/3, 2/3]); 36 | this.draw_position_line(eval_list.length, node); 37 | 38 | // We make lists of contiguous edges that can be drawn at once... 39 | 40 | let runs = this.make_runs(eval_list, width, height, node.graph_length_knower.val); 41 | 42 | // Draw our normal runs... 43 | 44 | graphctx.strokeStyle = "white"; 45 | graphctx.lineWidth = config.graph_line_width; 46 | graphctx.lineJoin = "round"; 47 | graphctx.setLineDash([]); 48 | 49 | for (let run of runs.normal_runs) { 50 | graphctx.beginPath(); 51 | graphctx.moveTo(run[0].x1, run[0].y1); 52 | for (let edge of run) { 53 | graphctx.lineTo(edge.x2, edge.y2); 54 | } 55 | graphctx.stroke(); 56 | } 57 | 58 | // Draw our dashed runs... 59 | 60 | graphctx.strokeStyle = "#999999"; 61 | graphctx.lineWidth = config.graph_line_width; 62 | graphctx.setLineDash([config.graph_line_width, config.graph_line_width]); 63 | 64 | for (let run of runs.dashed_runs) { 65 | graphctx.beginPath(); 66 | graphctx.moveTo(run[0].x1, run[0].y1); 67 | for (let edge of run) { 68 | graphctx.lineTo(edge.x2, edge.y2); 69 | } 70 | graphctx.stroke(); 71 | } 72 | }; 73 | 74 | grapher.make_runs = function(eval_list, width, height, graph_length) { 75 | 76 | // Returns an object with 2 arrays (normal_runs and dashed_runs). 77 | // Each of those is an array of arrays of contiguous edges that can be drawn at once. 78 | 79 | let all_edges = []; 80 | 81 | let last_x = null; 82 | let last_y = null; 83 | let last_n = null; 84 | 85 | // This loop creates all edges that we are going to draw, and marks each 86 | // edge as dashed or not... 87 | 88 | for (let n = 0; n < eval_list.length; n++) { 89 | 90 | let e = eval_list[n]; 91 | 92 | if (e !== null) { 93 | 94 | let x = width * n / graph_length; 95 | 96 | let y = (1 - e) * height; 97 | if (y < 1) y = 1; 98 | if (y > height - 2) y = height - 2; 99 | 100 | if (last_x !== null) { 101 | all_edges.push({ 102 | x1: last_x, 103 | y1: last_y, 104 | x2: x, 105 | y2: y, 106 | dashed: n - last_n !== 1, 107 | }); 108 | } 109 | 110 | last_x = x; 111 | last_y = y; 112 | last_n = n; 113 | } 114 | } 115 | 116 | // Now we make runs of contiguous edges that share a style... 117 | 118 | let normal_runs = []; 119 | let dashed_runs = []; 120 | 121 | let run = []; 122 | let current_meta_list = normal_runs; // Will point at normal_runs or dashed_runs. 123 | 124 | for (let edge of all_edges) { 125 | if ((edge.dashed && current_meta_list !== dashed_runs) || (!edge.dashed && current_meta_list !== normal_runs)) { 126 | if (run.length > 0) { 127 | current_meta_list.push(run); 128 | } 129 | current_meta_list = edge.dashed ? dashed_runs : normal_runs; 130 | run = []; 131 | } 132 | run.push(edge); 133 | } 134 | if (run.length > 0) { 135 | current_meta_list.push(run); 136 | } 137 | 138 | return {normal_runs, dashed_runs}; 139 | }; 140 | 141 | grapher.draw_horizontal_lines = function(width, height, y_fractions = [0.5]) { 142 | 143 | // Avoid anti-aliasing... (FIXME: we assumed graph size was even) 144 | let pixel_y_adjustment = config.graph_line_width % 2 === 0 ? 0 : -0.5; 145 | 146 | graphctx.strokeStyle = "#666666"; 147 | graphctx.lineWidth = config.graph_line_width; 148 | graphctx.setLineDash([config.graph_line_width, config.graph_line_width]); 149 | 150 | for (let y_fraction of y_fractions) { 151 | graphctx.beginPath(); 152 | graphctx.moveTo(0, height * y_fraction + pixel_y_adjustment); 153 | graphctx.lineTo(width, height * y_fraction + pixel_y_adjustment); 154 | graphctx.stroke(); 155 | } 156 | }; 157 | 158 | grapher.draw_position_line = function(eval_list_length, node) { 159 | 160 | if (eval_list_length < 2) { 161 | return; 162 | } 163 | 164 | let width = graph.width; 165 | let height = graph.height; 166 | 167 | // Avoid anti-aliasing... 168 | let pixel_x_adjustment = config.graph_line_width % 2 === 0 ? 0 : 0.5; 169 | 170 | let x = Math.floor(width * node.depth / node.graph_length_knower.val) + pixel_x_adjustment; 171 | 172 | graphctx.strokeStyle = node.is_main_line() ? "#6cccee" : "#ffff00"; 173 | graphctx.lineWidth = config.graph_line_width; 174 | graphctx.setLineDash([config.graph_line_width, config.graph_line_width]); 175 | 176 | graphctx.beginPath(); 177 | graphctx.moveTo(x, 0); 178 | graphctx.lineTo(x, height); 179 | graphctx.stroke(); 180 | 181 | }; 182 | 183 | grapher.node_from_click = function(node, event) { 184 | 185 | if (!event || config.graph_height <= 0) { 186 | return null; 187 | } 188 | 189 | let mousex = event.offsetX; 190 | if (typeof mousex !== "number") { 191 | return null; 192 | } 193 | 194 | let width = graph.width; 195 | if (typeof width !== "number" || width < 1) { 196 | return null; 197 | } 198 | 199 | let node_list = node.future_node_history(); 200 | if (node_list.length === 0) { 201 | return null; 202 | } 203 | 204 | // OK, everything is valid... 205 | 206 | let click_depth = Math.round(node.graph_length_knower.val * mousex / width); 207 | 208 | if (click_depth < 0) click_depth = 0; 209 | if (click_depth >= node_list.length) click_depth = node_list.length - 1; 210 | 211 | return node_list[click_depth]; 212 | }; 213 | 214 | return grapher; 215 | } 216 | -------------------------------------------------------------------------------- /files/src/renderer/60_pgn_utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function split_buffer(buf) { 4 | 5 | // Split a binary buffer into an array of binary buffers corresponding to lines. 6 | 7 | let lines = []; 8 | 9 | let push = (arr) => { 10 | if (arr.length > 0 && arr[arr.length - 1] === 13) { // Discard \r 11 | lines.push(Buffer.from(arr.slice(0, -1))); 12 | } else { 13 | lines.push(Buffer.from(arr)); 14 | } 15 | }; 16 | 17 | let a = 0; 18 | let b; 19 | 20 | if (buf.length > 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) { 21 | a = 3; // 1st slice will skip byte order mark (BOM). 22 | } 23 | 24 | for (b = 0; b < buf.length; b++) { 25 | let ch = buf[b]; 26 | if (ch === 10) { // Split on \n 27 | let line = buf.slice(a, b); 28 | push(line); 29 | a = b + 1; 30 | } 31 | } 32 | 33 | if (a !== b) { // We haven't added the last line before EOF. 34 | let line = buf.slice(a, b); 35 | push(line); 36 | } 37 | 38 | return lines; 39 | } 40 | 41 | function new_byte_pusher(size) { 42 | 43 | if (!size || size <= 0) { 44 | size = 16; 45 | } 46 | 47 | // I bet Node has something like this, but I didn't read the docs. 48 | 49 | return { 50 | 51 | storage: new Uint8Array(size), 52 | length: 0, // Both the length and also the next index to write to. 53 | 54 | push: function(c) { 55 | if (this.length >= this.storage.length) { 56 | let new_storage = new Uint8Array(this.storage.length * 2); 57 | for (let n = 0; n < this.storage.length; n++) { 58 | new_storage[n] = this.storage[n]; 59 | } 60 | this.storage = new_storage; 61 | } 62 | this.storage[this.length] = c; 63 | this.length++; 64 | }, 65 | 66 | reset: function() { 67 | this.length = 0; 68 | }, 69 | 70 | bytes: function() { 71 | return this.storage.slice(0, this.length); 72 | }, 73 | 74 | string: function() { 75 | return decoder.decode(this.bytes()); 76 | } 77 | }; 78 | } 79 | 80 | function new_pgndata(buf, indices) { // Made by the PGN file loader. Used by the hub. 81 | 82 | let ret = {buf, indices}; 83 | ret.source = "Unknown source"; 84 | 85 | ret.count = function() { 86 | return this.indices.length; 87 | }; 88 | 89 | ret.getrecord = function(n) { 90 | if (typeof n !== "number" || n < 0 || n >= this.indices.length) { 91 | return null; 92 | } 93 | return PreParsePGN(this.buf.slice(this.indices[n], this.indices[n + 1])); // if n + 1 is out-of-bounds, still works. 94 | }; 95 | 96 | ret.string = function(n) { 97 | if (typeof n !== "number" || n < 0 || n >= this.indices.length) { 98 | return ""; 99 | } 100 | return this.buf.slice(this.indices[n], this.indices[n + 1]).toString(); // For debugging. 101 | }; 102 | 103 | return ret; 104 | } 105 | 106 | // ------------------------------------------------------------------------------------------------------------------------------ 107 | 108 | function SavePGN(filename, node) { 109 | let s = make_pgn_string(node); 110 | try { 111 | fs.writeFileSync(filename, s); 112 | } catch (err) { 113 | alert(err); 114 | } 115 | } 116 | 117 | function PGNToClipboard(node) { 118 | let s = make_pgn_string(node); 119 | clipboard.writeText(s); 120 | } 121 | 122 | // ------------------------------------------------------------------------------------------------------------------------------ 123 | 124 | function make_pgn_string(node) { 125 | 126 | let root = node.get_root(); 127 | let start_fen = root.board.fen(true); 128 | 129 | if (!root.tags) { // This should be impossible. 130 | root.tags = Object.create(null); 131 | } 132 | 133 | // Let's set the Result tag if possible... 134 | 135 | let main_line_end = root.get_end(); 136 | let terminal_reason = main_line_end.terminal_reason(); 137 | 138 | if (terminal_reason === "") { 139 | // Pass - leave it unchanged since we know nothing 140 | } else if (terminal_reason === "Checkmate") { 141 | root.tags.Result = main_line_end.board.active === "w" ? "0-1" : "1-0"; 142 | } else { 143 | root.tags.Result = "1/2-1/2"; 144 | } 145 | 146 | // Convert tag object to PGN formatted strings... 147 | 148 | let tags = []; 149 | 150 | for (let t of ["Event", "Site", "Date", "Round", "White", "Black", "Result"]) { 151 | if (root.tags[t]) { 152 | let val = SafeStringPGN(UnsafeStringHTML(root.tags[t])); // Undo HTML escaping then add PGN escaping. 153 | tags.push(`[${t} "${val}"]`); 154 | } 155 | } 156 | 157 | if (start_fen !== "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") { 158 | if (root.board.normalchess === false) { 159 | tags.push(`[Variant "Chess960"]`); 160 | } 161 | tags.push(`[FEN "${start_fen}"]`); 162 | tags.push(`[SetUp "1"]`); 163 | } 164 | 165 | let movetext = make_movetext(root); 166 | let final = tags.join("\n") + "\n\n" + movetext + "\n"; 167 | return final; 168 | } 169 | 170 | function make_movetext(node) { 171 | 172 | let root = node.get_root(); 173 | let ordered_nodes = get_ordered_nodes(root); 174 | 175 | let tokens = []; 176 | 177 | for (let item of ordered_nodes) { 178 | 179 | if (item === root) continue; 180 | 181 | // As it stands, item could be a "(" or ")" string, or an actual node... 182 | 183 | if (typeof item === "string") { 184 | tokens.push(item); 185 | } else { 186 | let item_token = item.token(true); 187 | let subtokens = item_token.split(" ").filter(z => z !== ""); 188 | for (let subtoken of subtokens) { 189 | tokens.push(subtoken); 190 | } 191 | } 192 | } 193 | 194 | if (root.tags && root.tags.Result) { 195 | tokens.push(root.tags.Result); 196 | } else { 197 | tokens.push("*"); 198 | } 199 | 200 | // Now it's all about wrapping to 80 chars... 201 | 202 | let lines = []; 203 | let line = ""; 204 | 205 | for (let token of tokens) { 206 | if (line.length + token.length > 79) { 207 | if (line !== "") { 208 | lines.push(line); 209 | } 210 | line = token; 211 | } else { 212 | if (line.length > 0 && line.endsWith("(") === false && token !== ")") { 213 | line += " "; 214 | } 215 | line += token; 216 | } 217 | } 218 | if (line !== "") { 219 | lines.push(line); 220 | } 221 | 222 | return lines.join("\n"); 223 | } 224 | 225 | // The following is to order the nodes into the order they would be written 226 | // to screen or PGN. The result does contain root, which shouldn't be drawn. 227 | // 228 | // As a crude hack, the list also contains "(" and ")" elements to indicate 229 | // where brackets should be drawn. 230 | 231 | function get_ordered_nodes(node) { 232 | let list = []; 233 | __order_nodes(node, list, false); 234 | return list; 235 | } 236 | 237 | function __order_nodes(node, list, skip_self_flag) { 238 | 239 | // Write this node itself... 240 | 241 | if (!skip_self_flag) { 242 | list.push(node); 243 | } 244 | 245 | // Write descendents as long as there's no branching, 246 | // or return if we reach a node with no children. 247 | 248 | while (node.children.length === 1) { 249 | node = node.children[0]; 250 | list.push(node); 251 | } 252 | 253 | if (node.children.length === 0) { 254 | return; 255 | } 256 | 257 | // So multiple child nodes exist... 258 | 259 | let main_child = node.children[0]; 260 | list.push(main_child); 261 | 262 | for (let child of node.children.slice(1)) { 263 | list.push("("); 264 | __order_nodes(child, list, false); 265 | list.push(")"); 266 | } 267 | 268 | __order_nodes(main_child, list, true); 269 | } 270 | -------------------------------------------------------------------------------- /files/src/renderer/61_pgn_parse.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function new_pgn_record() { 4 | return { 5 | tags: Object.create(null), 6 | movebufs: [] 7 | }; 8 | } 9 | 10 | function PreParsePGN(buf) { // buf should be the buffer for a single game, only. 11 | 12 | // Partial parse of the buffer. Generates a tags object and a list of buffers, each of which is a line 13 | // in the movetext. Not so sure this approach makes sense any more, if it ever did. In particular, 14 | // there's no really great reason why the movetext needs to be split into lines at all. 15 | // 16 | // Never fails. Always returns a valid object (though possibly containing illegal movetext). 17 | 18 | let game = new_pgn_record(); 19 | let lines = split_buffer(buf); 20 | 21 | for (let rawline of lines) { 22 | 23 | if (rawline.length === 0) { 24 | continue; 25 | } 26 | 27 | if (rawline[0] === 37) { // Percent % sign is a special comment type. 28 | continue; 29 | } 30 | 31 | let tagline; 32 | 33 | if (game.movebufs.length === 0) { // If we have movetext then this can't be a tag line. 34 | if (rawline[0] === 91) { 35 | let s = decoder.decode(rawline).trim(); 36 | if (s.endsWith(`]`)) { 37 | tagline = s; 38 | } 39 | } 40 | } 41 | 42 | if (tagline) { 43 | 44 | tagline = tagline.slice(1, -1).trim(); // So now it's like: Foo "bar etc" 45 | 46 | let first_space_i = tagline.indexOf(` `); 47 | 48 | if (first_space_i === -1) { 49 | continue; 50 | } 51 | 52 | let key = tagline.slice(0, first_space_i).trim(); 53 | let value = tagline.slice(first_space_i + 1).trim(); 54 | 55 | if (value.startsWith(`"`)) value = value.slice(1); 56 | if (value.endsWith(`"`)) value = value.slice(0, -1); 57 | value = value.trim(); 58 | 59 | game.tags[key] = SafeStringHTML(UnsafeStringPGN(value)); // Undo PGN escaping then add HTML escaping. 60 | 61 | } else { 62 | 63 | game.movebufs.push(rawline); 64 | 65 | } 66 | } 67 | 68 | return game; 69 | } 70 | 71 | function LoadPGNRecord(o) { // This can throw! 72 | 73 | // Parse of the objects produced above, to generate a game tree. 74 | // Tags are placed into the root's own tags object. 75 | 76 | let startpos; 77 | 78 | if (o.tags.FEN) { // && o.tags.SetUp === "1" - but some writers don't do this. 79 | try { 80 | startpos = LoadFEN(o.tags.FEN); 81 | } catch (err) { 82 | throw err; // Rethrow - the try/catch here is just to be explicit about this case. 83 | } 84 | } else { 85 | startpos = LoadFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); 86 | } 87 | 88 | let root = NewRoot(startpos); 89 | let node = root; 90 | 91 | let inside_brace = false; // {} are comments. Braces do not nest. 92 | 93 | let callstack = []; // When a parenthesis "(" opens, we record the node to "return" to later, on the "callstack". 94 | 95 | let token = new_byte_pusher(); 96 | 97 | let finished = false; 98 | 99 | for (let rawline of o.movebufs) { 100 | 101 | if (rawline.length === 0) { 102 | continue; 103 | } 104 | 105 | if (rawline[0] === 37) { // Percent % sign is a special comment type. 106 | continue; 107 | } 108 | 109 | for (let i = 0; i < rawline.length; i++) { 110 | 111 | // Note that, when adding characters to our current token, we peek forwards 112 | // to check if it's the end of the token. Therefore, it's safe for these 113 | // special characters to fire a continue immediately. 114 | 115 | let c = rawline[i]; 116 | 117 | if (c === 123) { // The opening brace { for a comment 118 | inside_brace = true; 119 | continue; 120 | } 121 | 122 | if (inside_brace) { 123 | if (c === 125) { // The closing brace } 124 | inside_brace = false; 125 | } 126 | continue; 127 | } 128 | 129 | if (c === 40) { // The opening parenthesis ( 130 | callstack.push(node); 131 | node = node.parent; // Unplay the last move. 132 | continue; 133 | } 134 | 135 | if (c === 41) { // The closing parenthesis ) 136 | node = callstack[callstack.length - 1]; 137 | callstack = callstack.slice(0, -1); 138 | continue; 139 | } 140 | 141 | // So... 142 | 143 | token.push(c); 144 | 145 | // Is the current token complete? 146 | // We'll start a new token when we see any of the following... 147 | 148 | let peek = rawline[i + 1]; 149 | 150 | if ( 151 | peek === undefined || // end of line 152 | peek <= 32 || // whitespace 153 | peek === 40 || // ( 154 | peek === 41 || // ) 155 | peek === 46 || // . 156 | peek === 123) { // { 157 | 158 | let s = token.string().trim(); 159 | token.reset(); // For the next round. 160 | 161 | // The above conditional means "." can only appear as the first character. 162 | // Strings like "..." get decomposed to a series of "." tokens since each one terminates the token in front of it. 163 | 164 | if (s[0] === ".") { 165 | s = s.slice(1); // s is now guaranteed not to start with "." 166 | } 167 | 168 | // Parse s. 169 | 170 | if (s === "" || s === "+" || s.startsWith("$") || StringIsNumeric(s)) { 171 | // Useless token. 172 | continue; 173 | } 174 | 175 | if (s === "1/2-1/2" || s === "1-0" || s === "0-1" || s === "*") { 176 | finished = true; 177 | break; 178 | } 179 | 180 | // Probably an actual move... 181 | 182 | let [move, error] = node.board.parse_pgn(s); 183 | 184 | if (error) { 185 | 186 | // If the problem specifically is one of Kd4, Ke4, Kd5, Ke5, it's probably just a DGT board thing 187 | // due to the kings being moved to indicate the result. 188 | 189 | if (s.includes("Kd4") || s.includes("Ke4") || s.includes("Kd5") || s.includes("Ke5") || 190 | s.includes("Kxd4") || s.includes("Kxe4") || s.includes("Kxd5") || s.includes("Kxe5")) 191 | { 192 | finished = true; 193 | break; 194 | } else { 195 | DestroyTree(root); 196 | throw `"${s}" -- ${error}`; 197 | } 198 | } 199 | 200 | node = node.make_move(move, true); 201 | } 202 | } 203 | 204 | if (finished) { 205 | break; 206 | } 207 | } 208 | 209 | // Save all tags into the root. 210 | 211 | if (!root.tags) { 212 | root.tags = Object.create(null); 213 | } 214 | for (let key of Object.keys(o.tags)) { 215 | root.tags[key] = o.tags[key]; 216 | } 217 | 218 | return root; 219 | } 220 | -------------------------------------------------------------------------------- /files/src/renderer/65_loaders.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Non-blocking loader objects. 4 | // 5 | // Implementation rule: The callback property is non-null iff it's still possible that the load will succeed. 6 | // If callback === null this implies that shutdown() has already been called at least once. 7 | // 8 | // Also, every loader starts itself via setTimeout so that the caller can finish whatever it was doing first. 9 | // This prevents some weird inconsistency with order-of-events (whether it matters I don't know). 10 | // ------------------------------------------------------------------------------------------------------------------------------ 11 | 12 | function NewFastPGNLoader(foo, callback) { 13 | 14 | // foo is allowed to be filepath or Buffer 15 | 16 | if (typeof foo !== "string" && foo instanceof Buffer === false) { 17 | throw "NewFastPGNLoader() bad call"; 18 | } 19 | 20 | let loader = Object.create(null); 21 | loader.type = "pgn"; 22 | loader.starttime = performance.now(); 23 | 24 | loader.callback = callback; 25 | loader.msg = "Loading PGN..."; 26 | loader.buf = null; 27 | loader.indices = []; 28 | 29 | loader.off = 0; 30 | loader.phase = 1; 31 | loader.search = Buffer.from("\n\n["); 32 | loader.fix = 2; // Where the [ char will be 33 | 34 | loader.shutdown = function() { 35 | this.callback = null; 36 | this.msg = ""; 37 | this.buf = null; 38 | this.indices = null; 39 | }; 40 | 41 | loader.load = function(foo) { 42 | if (this.callback) { 43 | if (foo instanceof Buffer) { 44 | this.buf = foo; 45 | this.continue(); 46 | } else { 47 | fs.readFile(foo, (err, data) => { 48 | if (this.callback) { // Must test again, because this is later. 49 | if (err) { 50 | let cb = this.callback; cb(err, null); 51 | this.shutdown(); 52 | } else { 53 | this.buf = data; 54 | this.continue(); 55 | } 56 | } 57 | }); 58 | } 59 | } 60 | }; 61 | 62 | loader.continue = function() { 63 | 64 | if (!this.callback) { 65 | return; 66 | } 67 | 68 | if (this.indices.length === 0 && this.buf.length > 0) { 69 | this.indices.push(0); 70 | } 71 | 72 | let continuetime = performance.now(); 73 | 74 | while (true) { 75 | 76 | let index = this.buf.indexOf(this.search, this.off); 77 | 78 | if (index === -1) { 79 | if (this.phase === 1) { 80 | this.phase = 2; 81 | this.search = Buffer.from("\n\r\n["); 82 | this.fix = 3; 83 | this.off = 0; 84 | continue; 85 | } else { 86 | break; 87 | } 88 | } 89 | 90 | this.indices.push(index + this.fix); 91 | this.off = index + 1; 92 | 93 | if (this.indices.length % 100 === 0) { 94 | if (performance.now() - continuetime > 10) { 95 | this.msg = `Loading PGN... ${this.indices.length} games`; 96 | setTimeout(() => {this.continue();}, 10); 97 | return; 98 | } 99 | } 100 | } 101 | 102 | // Once, after the while loop is broken... 103 | 104 | this.indices.sort((a, b) => a - b); 105 | 106 | let ret = new_pgndata(this.buf, this.indices); 107 | let cb = this.callback; cb(null, ret); 108 | this.shutdown(); 109 | }; 110 | 111 | setTimeout(() => {loader.load(foo);}, 0); 112 | return loader; 113 | } 114 | 115 | // ------------------------------------------------------------------------------------------------------------------------------ 116 | 117 | function NewPolyglotBookLoader(filename, callback) { 118 | 119 | let loader = Object.create(null); 120 | loader.type = "book"; 121 | loader.starttime = performance.now(); 122 | 123 | loader.callback = callback; 124 | loader.msg = "Loading book..."; 125 | 126 | loader.shutdown = function() { 127 | this.callback = null; 128 | this.msg = ""; 129 | }; 130 | 131 | loader.load = function(filename) { 132 | if (this.callback) { 133 | fs.readFile(filename, (err, data) => { 134 | if (this.callback) { // Must test again, because this is later. 135 | if (err) { 136 | let cb = this.callback; cb(err, null); 137 | this.shutdown(); 138 | } else { 139 | let cb = this.callback; cb(null, data); 140 | this.shutdown(); 141 | } 142 | } 143 | }); 144 | } 145 | }; 146 | 147 | setTimeout(() => {loader.load(filename);}, 0); 148 | return loader; 149 | } 150 | 151 | // ------------------------------------------------------------------------------------------------------------------------------ 152 | 153 | function NewPGNBookLoader(filename, callback) { 154 | 155 | let loader = Object.create(null); 156 | loader.type = "book"; 157 | loader.starttime = performance.now(); 158 | 159 | loader.callback = callback; 160 | loader.msg = "Loading book..."; 161 | loader.buf = null; 162 | loader.book = []; 163 | loader.pgndata = null; 164 | loader.fastloader = null; 165 | 166 | loader.n = 0; 167 | 168 | loader.shutdown = function() { 169 | this.callback = null; 170 | this.msg = ""; 171 | this.buf = null; 172 | this.book = null; 173 | this.pgndata = null; 174 | if (this.fastloader) { 175 | this.fastloader.shutdown(); 176 | this.fastloader = null; 177 | } 178 | }; 179 | 180 | loader.load = function(filename) { 181 | if (this.callback) { 182 | this.fastloader = NewFastPGNLoader(filename, (err, pgndata) => { 183 | if (this.callback) { // Must test again, because this is later. 184 | if (err) { 185 | let cb = this.callback; cb(err, null); 186 | this.shutdown(); 187 | } else { 188 | this.pgndata = pgndata; 189 | this.continue(); 190 | } 191 | } 192 | }); 193 | } 194 | }; 195 | 196 | loader.continue = function() { 197 | 198 | if (!this.callback) { 199 | return; 200 | } 201 | 202 | let continuetime = performance.now(); 203 | let count = this.pgndata.count(); 204 | 205 | while (true) { 206 | 207 | if (this.n >= count) { 208 | break; 209 | } 210 | 211 | let o = this.pgndata.getrecord(this.n++); 212 | 213 | try { 214 | let root = LoadPGNRecord(o); // Note that this calls DestroyTree() itself if it must throw. 215 | this.book = AddTreeToBook(root, this.book); 216 | DestroyTree(root); 217 | } catch (err) { 218 | // 219 | } 220 | 221 | if (performance.now() - continuetime > 10) { 222 | this.msg = `Loading book... ${(100 * (this.n / count)).toFixed(0)}%`; 223 | setTimeout(() => {this.continue();}, 10); 224 | return; 225 | } 226 | } 227 | 228 | // Once, after the while loop is broken... 229 | 230 | SortAndDeclutterPGNBook(this.book); 231 | let ret = this.book; // Just in case I ever replace the direct cb() with a setTimeout (shutdown would cause this.book to be null). 232 | let cb = this.callback; cb(null, ret); 233 | this.shutdown(); 234 | }; 235 | 236 | setTimeout(() => {loader.load(filename);}, 0); 237 | return loader; 238 | } 239 | -------------------------------------------------------------------------------- /files/src/renderer/71_tree_handler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // The point is that updating the node should trigger an immediate redraw. The caller doesn't need 4 | // to care about redrawing. Ideally, this object should be able to make good decisions about how 5 | // to best redraw. 6 | 7 | function NewTreeHandler() { 8 | let handler = Object.create(null); 9 | Object.assign(handler, tree_manipulation_props); 10 | Object.assign(handler, tree_draw_props); 11 | handler.root = NewRoot(); 12 | handler.node = handler.root; 13 | handler.node.table.autopopulate(handler.node); 14 | return handler; 15 | } 16 | 17 | let tree_manipulation_props = { 18 | 19 | // Since we use Object.assign(), it's bad form to have any deep objects in the props. 20 | 21 | tree_version: 0, // Increment every time the tree structure changes. 22 | root: null, 23 | node: null, 24 | 25 | // Where relevant, return values of the methods are whether this.node changed - 26 | // i.e. whether the hub has to call position_changed() 27 | 28 | replace_tree: function(root) { 29 | DestroyTree(this.root); 30 | this.root = root; 31 | this.node = root; 32 | this.node.table.autopopulate(this.node); 33 | this.tree_version++; 34 | this.dom_from_scratch(); 35 | return true; 36 | }, 37 | 38 | set_node: function(node) { 39 | 40 | // Note that we may call dom_easy_highlight_change() so don't 41 | // rely on this to draw any nodes that never got drawn. 42 | 43 | if (!node || node === this.node || node.destroyed) { 44 | return false; 45 | } 46 | 47 | let original_node = this.node; 48 | this.node = node; 49 | 50 | if (original_node.is_same_line(this.node)) { // This test is super-fast if one node is a parent of the other 51 | this.dom_easy_highlight_change(); 52 | } else { 53 | this.dom_from_scratch(); 54 | } 55 | 56 | return true; 57 | }, 58 | 59 | prev: function() { 60 | return this.set_node(this.node.parent); // OK if undefined 61 | }, 62 | 63 | next: function() { 64 | return this.set_node(this.node.children[0]); // OK if undefined 65 | }, 66 | 67 | goto_root: function() { 68 | return this.set_node(this.root); 69 | }, 70 | 71 | goto_end: function() { 72 | return this.set_node(this.node.get_end()); 73 | }, 74 | 75 | previous_sibling: function() { 76 | if (!this.node.parent || this.node.parent.children.length < 2) { 77 | return false; 78 | } 79 | if (this.node.parent.children[0] === this.node) { 80 | return this.set_node(this.node.parent.children[this.node.parent.children.length - 1]); 81 | } 82 | for (let i = this.node.parent.children.length - 1; i > 0; i--) { 83 | if (this.node.parent.children[i] === this.node) { 84 | return this.set_node(this.node.parent.children[i - 1]); 85 | } 86 | } 87 | return false; // Can't get here. 88 | }, 89 | 90 | next_sibling: function() { 91 | if (!this.node.parent || this.node.parent.children.length < 2) { 92 | return false; 93 | } 94 | if (this.node.parent.children[this.node.parent.children.length - 1] === this.node) { 95 | return this.set_node(this.node.parent.children[0]); 96 | } 97 | for (let i = 0; i < this.node.parent.children.length - 1; i++) { 98 | if (this.node.parent.children[i] === this.node) { 99 | return this.set_node(this.node.parent.children[i + 1]); 100 | } 101 | } 102 | return false; // Can't get here. 103 | }, 104 | 105 | return_to_main_line: function() { 106 | let node = this.node.return_to_main_line_helper(); 107 | if (this.node === node) { 108 | return false; 109 | } 110 | this.node = node; 111 | this.dom_from_scratch(); 112 | return true; 113 | }, 114 | 115 | delete_node: function() { 116 | 117 | if (!this.node.parent) { 118 | this.delete_children(); 119 | return false; 120 | } 121 | 122 | let parent = this.node.parent; 123 | this.node.detach(); 124 | this.node = parent; 125 | this.tree_version++; 126 | this.dom_from_scratch(); 127 | return true; 128 | }, 129 | 130 | make_move: function(s) { 131 | 132 | // s must be exactly a legal move, including having promotion char iff needed (e.g. e2e1q) 133 | 134 | let next_node_id__initial = next_node_id; 135 | this.node = this.node.make_move(s); 136 | 137 | if (next_node_id !== next_node_id__initial) { // NewNode() was called 138 | this.tree_version++; 139 | } 140 | 141 | this.dom_from_scratch(); // Could potentially call something else here. 142 | return true; 143 | }, 144 | 145 | make_move_sequence: function(moves, set_this_node = true) { 146 | 147 | if (Array.isArray(moves) === false || moves.length === 0) { 148 | return false; 149 | } 150 | 151 | let next_node_id__initial = next_node_id; 152 | 153 | let node = this.node; 154 | for (let s of moves) { 155 | node = node.make_move(s); // Calling the node's make_move() method, not handler's 156 | } 157 | 158 | if (set_this_node) { 159 | this.node = node; 160 | } 161 | 162 | if (next_node_id !== next_node_id__initial) { // NewNode() was called 163 | this.tree_version++; 164 | } 165 | 166 | this.dom_from_scratch(); 167 | return true; 168 | }, 169 | 170 | add_move_sequence: function(moves) { 171 | return this.make_move_sequence(moves, false); 172 | }, 173 | 174 | // ------------------------------------------------------------------------------------------------------------- 175 | // The following methods don't ever change this.node - so the caller has no action to take. No return value. 176 | 177 | promote_to_main_line: function() { 178 | 179 | let node = this.node; 180 | let changed = false; 181 | 182 | while (node.parent) { 183 | if (node.parent.children[0] !== node) { 184 | for (let n = 1; n < node.parent.children.length; n++) { 185 | if (node.parent.children[n] === node) { 186 | node.parent.children[n] = node.parent.children[0]; 187 | node.parent.children[0] = node; 188 | changed = true; 189 | break; 190 | } 191 | } 192 | } 193 | node = node.parent; 194 | } 195 | 196 | if (changed) { 197 | this.tree_version++; 198 | this.dom_from_scratch(); 199 | } 200 | }, 201 | 202 | promote: function() { 203 | 204 | let node = this.node; 205 | let changed = false; 206 | 207 | while (node.parent) { 208 | if (node.parent.children[0] !== node) { 209 | for (let n = 1; n < node.parent.children.length; n++) { 210 | if (node.parent.children[n] === node) { 211 | let swapper = node.parent.children[n - 1]; 212 | node.parent.children[n - 1] = node; 213 | node.parent.children[n] = swapper; 214 | changed = true; 215 | break; 216 | } 217 | } 218 | break; // 1 tree change only 219 | } 220 | node = node.parent; 221 | } 222 | 223 | if (changed) { 224 | this.tree_version++; 225 | this.dom_from_scratch(); 226 | } 227 | }, 228 | 229 | delete_other_lines: function() { 230 | 231 | this.promote_to_main_line(); 232 | 233 | let changed = false; 234 | let node = this.root; 235 | 236 | while (node.children.length > 0) { 237 | for (let child of node.children.slice(1)) { 238 | child.detach(); 239 | changed = true; 240 | } 241 | node = node.children[0]; 242 | } 243 | 244 | if (changed) { 245 | this.tree_version++; 246 | this.dom_from_scratch(); // This may be the 2nd draw since promote_to_main_line() may have drawn. Bah. 247 | } 248 | }, 249 | 250 | delete_children: function() { 251 | 252 | if (this.node.children.length > 0) { 253 | for (let child of this.node.children) { 254 | child.detach(); 255 | } 256 | this.tree_version++; 257 | this.dom_from_scratch(); 258 | } 259 | }, 260 | 261 | delete_siblings: function() { 262 | 263 | let changed = false; 264 | 265 | if (this.node.parent) { 266 | for (let sibling of this.node.parent.children) { 267 | if (sibling !== this.node) { 268 | sibling.detach(); 269 | changed = true; 270 | } 271 | } 272 | } 273 | 274 | if (changed) { 275 | this.tree_version++; 276 | this.dom_from_scratch(); 277 | } 278 | }, 279 | 280 | // ------------------------------------------------------------------------------------------------------------- 281 | 282 | handle_click: function(event) { 283 | 284 | let n = EventPathN(event, "node_"); 285 | if (typeof n !== "number") { 286 | return false; 287 | } 288 | 289 | let node = live_nodes[n.toString()]; 290 | 291 | if (!node || node.destroyed) { // Probably the check for .destroyed is unnecessary. 292 | return false; 293 | } 294 | 295 | return this.set_node(node); 296 | }, 297 | }; 298 | 299 | -------------------------------------------------------------------------------- /files/src/renderer/72_tree_draw.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | let tree_draw_props = { 4 | 5 | // Since we use Object.assign(), it's bad form to have any deep objects in the props. 6 | 7 | ordered_nodes_cache: null, 8 | ordered_nodes_cache_version: -1, 9 | 10 | dom_easy_highlight_change: function() { 11 | 12 | // When the previously highlighted node and the newly highlighted node are on the same line, 13 | // with the same end-of-line, meaning no gray / white changes are needed. 14 | 15 | let dom_highlight = this.get_movelist_highlight(); 16 | let highlight_class; 17 | 18 | if (dom_highlight && dom_highlight.classList.contains("movelist_highlight_yellow")) { 19 | highlight_class = "movelist_highlight_yellow"; 20 | } else { 21 | highlight_class = "movelist_highlight_blue"; 22 | } 23 | 24 | if (dom_highlight) { 25 | dom_highlight.classList.remove("movelist_highlight_blue"); 26 | dom_highlight.classList.remove("movelist_highlight_yellow"); 27 | } 28 | 29 | let dom_node = document.getElementById(`node_${this.node.id}`); 30 | 31 | if (dom_node) { 32 | dom_node.classList.add(highlight_class); 33 | } 34 | 35 | this.fix_scrollbar_position(); 36 | }, 37 | 38 | dom_from_scratch: function() { 39 | 40 | // Some prep-work (we need to undo all this at the end)... 41 | 42 | let line_end = this.node.get_end(); 43 | 44 | let foo = line_end; 45 | while (foo) { 46 | foo.current_line = true; // These nodes will be coloured white, others gray 47 | foo = foo.parent; 48 | } 49 | 50 | let main_line_end = this.root.get_end(); 51 | main_line_end.main_line_end = true; 52 | 53 | // Begin... 54 | 55 | if (this.ordered_nodes_cache_version !== this.tree_version) { 56 | this.ordered_nodes_cache = get_ordered_nodes(this.root); 57 | this.ordered_nodes_cache_version = this.tree_version; 58 | } 59 | 60 | let pseudoelements = []; // Objects containing opening span string `` and text string 61 | 62 | for (let item of this.ordered_nodes_cache) { 63 | 64 | if (item === this.root) { 65 | continue; 66 | } 67 | 68 | // As a crude hack, the item can be a bracket string. 69 | // Deal with that first... 70 | 71 | if (typeof item === "string") { 72 | pseudoelements.push({ 73 | opener: "", 74 | text: item, 75 | closer: "" 76 | }); 77 | continue; 78 | } 79 | 80 | // So item is a real node... 81 | 82 | let node = item; 83 | let classes = []; 84 | 85 | if (node === this.node) { 86 | if (node.is_main_line()) { 87 | classes.push("movelist_highlight_blue"); 88 | } else { 89 | classes.push("movelist_highlight_yellow"); 90 | } 91 | } 92 | 93 | if (node.current_line) { 94 | classes.push("white"); // Otherwise, inherits gray colour from movelist CSS 95 | } 96 | 97 | pseudoelements.push({ 98 | opener: ``, 99 | text: node.token(), 100 | closer: `` 101 | }); 102 | } 103 | 104 | let all_spans = []; 105 | 106 | for (let n = 0; n < pseudoelements.length; n++) { 107 | 108 | let p = pseudoelements[n]; 109 | let nextp = pseudoelements[n + 1]; // Possibly undefined 110 | 111 | if (!nextp || (p.text !== "(" && nextp.text !== ")")) { 112 | p.text += " "; 113 | } 114 | 115 | all_spans.push(`${p.opener}${p.text}${p.closer}`); 116 | } 117 | 118 | movelist.innerHTML = all_spans.join(""); 119 | 120 | // Undo the damage to our tree from the start... 121 | 122 | foo = line_end; 123 | while(foo) { 124 | delete foo.current_line; 125 | foo = foo.parent; 126 | } 127 | 128 | delete main_line_end.main_line_end; 129 | 130 | // And finally... 131 | 132 | this.fix_scrollbar_position(); 133 | }, 134 | 135 | // Helpers... 136 | 137 | get_movelist_highlight: function() { 138 | let elements = document.getElementsByClassName("movelist_highlight_blue"); 139 | if (elements && elements.length > 0) { 140 | return elements[0]; 141 | } 142 | elements = document.getElementsByClassName("movelist_highlight_yellow"); 143 | if (elements && elements.length > 0) { 144 | return elements[0]; 145 | } 146 | return null; 147 | }, 148 | 149 | fix_scrollbar_position: function() { 150 | let highlight = this.get_movelist_highlight(); 151 | if (highlight) { 152 | let top = highlight.offsetTop - movelist.offsetTop; 153 | if (top < movelist.scrollTop) { 154 | movelist.scrollTop = top; 155 | } 156 | let bottom = top + highlight.offsetHeight; 157 | if (bottom > movelist.scrollTop + movelist.offsetHeight) { 158 | movelist.scrollTop = bottom - movelist.offsetHeight; 159 | } 160 | } else { 161 | movelist.scrollTop = 0; 162 | } 163 | }, 164 | }; 165 | -------------------------------------------------------------------------------- /files/src/renderer/75_looker.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Rate limit strategy - thanks to Sopel: 4 | // 5 | // .running holds the item in-flight. 6 | // .pending holds a single item to send after. 7 | // 8 | // Note: Don't store the retrieved info in the node.table, because the logic 9 | // there is already a bit convoluted with __touched, __ghost and whatnot (sadly). 10 | // 11 | // Note: format of entries in the DB is {type: "foo", moves: {}} 12 | // where moves is a map of string --> object 13 | 14 | function NewLooker() { 15 | let looker = Object.create(null); 16 | looker.running = null; 17 | looker.pending = null; 18 | looker.all_dbs = Object.create(null); 19 | looker.bans = Object.create(null); // db --> time of last rate-limit 20 | Object.assign(looker, looker_props); 21 | return looker; 22 | } 23 | 24 | let looker_props = { 25 | 26 | clear_queue: function() { 27 | this.running = null; 28 | this.pending = null; 29 | }, 30 | 31 | add_to_queue: function(board) { 32 | 33 | if (!config.looker_api || !board.normalchess) { 34 | return; 35 | } 36 | 37 | if (!config.look_past_25 && board.fullmove > 25) { 38 | return; 39 | } 40 | 41 | // Is there a reason the test for whether we've already looked up this 42 | // position isn't done here, but is done later at query_api()? I forget. 43 | 44 | let query = { // Since queries are objects, different queries can always be told apart. 45 | board: board, 46 | db_name: config.looker_api 47 | }; 48 | 49 | if (!this.running) { 50 | this.send_query(query); 51 | } else { 52 | this.pending = query; 53 | } 54 | }, 55 | 56 | send_query: function(query) { 57 | 58 | this.running = query; 59 | 60 | // It is ESSENTIAL that every call to send_query() eventually generates a call to query_complete() 61 | // so that the item gets removed from the queue. While we don't really need to use promises, doing 62 | // it as follows lets me just have a single place where query_complete() is called. I guess. 63 | 64 | this.query_api(query).catch(error => { 65 | console.log("Query failed:", error); 66 | }).finally(() => { 67 | this.query_complete(query); 68 | }); 69 | }, 70 | 71 | query_complete: function(query) { 72 | 73 | if (this.running !== query) { // Possible if clear_queue() was called. 74 | return; 75 | } 76 | 77 | let next_query = this.pending; 78 | 79 | this.running = null; 80 | this.pending = null; 81 | 82 | if (next_query) { 83 | this.send_query(next_query); 84 | } 85 | }, 86 | 87 | get_db: function(db_name) { // Creates it if needed. 88 | 89 | if (typeof db_name !== "string") { 90 | return null; 91 | } 92 | 93 | if (!this.all_dbs[db_name]) { 94 | this.all_dbs[db_name] = Object.create(null); 95 | } 96 | 97 | return this.all_dbs[db_name]; 98 | }, 99 | 100 | new_entry: function(db_name, board) { // Creates a new (empty) entry in the database (to be populated elsewhere) and returns it. 101 | 102 | let entry = { 103 | type: db_name, 104 | moves: {}, 105 | }; 106 | 107 | let db = this.get_db(db_name); 108 | db[board.fen()] = entry; 109 | return entry; 110 | }, 111 | 112 | lookup: function(db_name, board) { 113 | 114 | // Return the full entry for a position. When repeatedly called with the same params, this should 115 | // return the same object (unless it changes of course). Returns null if not available. 116 | 117 | let db = this.get_db(db_name); 118 | if (db) { // Remember get_db() can return null. 119 | let ret = db[board.fen()]; 120 | if (ret) { 121 | return ret; 122 | } 123 | } 124 | return null; // I guess we tend to like null over undefined. (Bad habit?) 125 | }, 126 | 127 | set_ban: function(db_name) { 128 | this.bans[db_name] = performance.now(); 129 | }, 130 | 131 | query_api(query) { // Returns a promise, which is solely used by the caller to attach some cleanup catch/finally() 132 | 133 | if (this.lookup(query.db_name, query.board)) { // We already have a result for this board. 134 | return Promise.resolve(); // Consider this case a satisfactory result. 135 | } 136 | 137 | if (this.bans[query.db_name]) { 138 | if (performance.now() - this.bans[query.db_name] < 60000) { // No requests within 1 minute of the ban. 139 | return Promise.resolve(); // Consider this case a satisfactory result. 140 | } 141 | } 142 | 143 | let friendly_fen = query.board.fen(true); 144 | let fen_for_web = ReplaceAll(friendly_fen, " ", "%20"); 145 | 146 | let url; 147 | 148 | if (query.db_name === "chessdbcn") { 149 | url = `http://www.chessdb.cn/cdb.php?action=queryall&json=1&board=${fen_for_web}`; 150 | } else if (query.db_name === "lichess_masters") { 151 | url = `http://explorer.lichess.ovh/masters?topGames=0&fen=${fen_for_web}`; 152 | } else if (query.db_name === "lichess_plebs") { 153 | url = `http://explorer.lichess.ovh/lichess?variant=standard&topGames=0&recentGames=0&fen=${fen_for_web}`; 154 | } else { 155 | return Promise.reject(new Error("Bad db_name")); 156 | } 157 | 158 | return fetch(url).then(response => { 159 | if (response.status === 429) { // rate limit hit 160 | this.set_ban(query.db_name); 161 | hub.set_special_message("429 Too Many Requests", "red", 5000); // relies on hub being in script/global scope, which it is 162 | throw new Error("rate limited"); 163 | } 164 | if (!response.ok) { // ok means status in range 200-299 165 | throw new Error("response.ok was false"); 166 | } 167 | return response.json(); 168 | }).then(raw_object => { 169 | this.handle_response_object(query, raw_object); 170 | }); 171 | }, 172 | 173 | handle_response_object: function(query, raw_object) { 174 | 175 | let board = query.board; 176 | let o = this.new_entry(query.db_name, board); 177 | 178 | // If the raw_object is invalid, now's the time to return - after the empty object 179 | // has been stored in the database, so we don't do this lookup again. 180 | 181 | if (typeof raw_object !== "object" || raw_object === null || Array.isArray(raw_object.moves) === false) { 182 | return; // This can happen e.g. if the position is checkmate. 183 | } 184 | 185 | // Our Lichess moves need to know the total number of games so they can return valid stats. 186 | // While the total is available as raw_object.white + raw_object.black + raw_object.draws, 187 | // it's probably better to sum up the items that we're given. 188 | 189 | let lichess_position_total = 0; 190 | 191 | if (query.db_name === "lichess_masters" || query.db_name === "lichess_plebs") { 192 | for (let raw_item of raw_object.moves) { 193 | lichess_position_total += raw_item.white + raw_item.black + raw_item.draws; 194 | } 195 | } 196 | 197 | // Now add moves to the entry... 198 | 199 | for (let raw_item of raw_object.moves) { 200 | 201 | let move = raw_item.uci; 202 | move = board.c960_castling_converter(move); 203 | 204 | if (query.db_name === "chessdbcn") { 205 | o.moves[move] = new_chessdbcn_move(board, raw_item); 206 | } else if (query.db_name === "lichess_masters" || query.db_name === "lichess_plebs") { 207 | o.moves[move] = new_lichess_move(board, raw_item, lichess_position_total); 208 | } 209 | } 210 | 211 | // Note that even if we get no info, we still leave the empty object o in the database, 212 | // and this allows us to know that we've done this search already. 213 | }, 214 | }; 215 | 216 | 217 | // Below are some functions which use the info a server sends about a single move to create our 218 | // own object containing just what we need (and with a prototype containing some useful methods). 219 | 220 | 221 | function new_chessdbcn_move(board, raw_item) { // The object with info about a single move in a chessdbcn object. 222 | let ret = Object.create(chessdbcn_move_props); 223 | ret.active = board.active; 224 | ret.score = raw_item.score / 100; 225 | return ret; 226 | } 227 | 228 | let chessdbcn_move_props = { 229 | 230 | text: function(pov) { // pov can be null for current 231 | 232 | let score = this.score; 233 | 234 | if ((pov === "w" && this.active === "b") || (pov === "b" && this.active === "w")) { 235 | score = 0 - this.score; 236 | } 237 | 238 | let s = score.toFixed(2); 239 | if (s !== "0.00" && s[0] !== "-") { 240 | s = "+" + s; 241 | } 242 | 243 | return `API: ${s}`; 244 | }, 245 | 246 | sort_score: function() { 247 | return this.score; 248 | }, 249 | }; 250 | 251 | function new_lichess_move(board, raw_item, position_total) { // The object with info about a single move in a lichess object. 252 | let ret = Object.create(lichess_move_props); 253 | ret.active = board.active; 254 | ret.white = raw_item.white; 255 | ret.black = raw_item.black; 256 | ret.draws = raw_item.draws; 257 | ret.total = raw_item.white + raw_item.draws + raw_item.black; 258 | ret.position_total = position_total; 259 | return ret; 260 | } 261 | 262 | let lichess_move_props = { 263 | 264 | text: function(pov) { // pov can be null for current 265 | 266 | let actual_pov = pov ? pov : this.active; 267 | let wins = actual_pov === "w" ? this.white : this.black; 268 | let ev = (wins + (this.draws / 2)) / this.total; 269 | 270 | let win_string = (ev * 100).toFixed(1); 271 | let weight_string = (100 * this.total / this.position_total).toFixed(0); 272 | 273 | return `API win: ${win_string}% freq: ${weight_string}% [${NString(this.total)}]`; 274 | }, 275 | 276 | sort_score: function() { 277 | return this.total; 278 | }, 279 | }; 280 | 281 | -------------------------------------------------------------------------------- /files/src/renderer/80_info.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function NewInfoHandler() { 4 | 5 | let ih = Object.create(null); 6 | Object.assign(ih, info_misc_props); 7 | Object.assign(ih, info_receiver_props); 8 | Object.assign(ih, arrow_props); 9 | Object.assign(ih, infobox_props); 10 | 11 | // Array of possible one-click moves. Updated by draw_arrows(). Used elsewhere. 12 | ih.one_click_moves = New2DArray(8, 8, null); 13 | 14 | // Clickable elements in the infobox. Updated by draw_infobox(). Used elsewhere. 15 | ih.info_clickers = []; 16 | ih.info_clickers_node_id = null; 17 | 18 | // Infobox stuff, used solely to skip redraws... 19 | ih.last_drawn_node_id = null; 20 | ih.last_drawn_version = null; 21 | ih.last_drawn_highlight = null; 22 | ih.last_drawn_highlight_class = null; 23 | ih.last_drawn_length = 0; 24 | ih.last_drawn_searchmoves = []; 25 | ih.last_drawn_allow_inactive_focus = null; 26 | ih.last_drawn_lookup_object = null; 27 | 28 | // Info about engine cycles. These aren't reset even when the engine resets. 29 | ih.engine_cycle = 0; // Count of "go" commands emitted. Since Engine can change, can't store this in Engine objects 30 | ih.engine_subcycle = 0; // Count of how many times we have seen "multipv 1" - each time it's a new "block" of info 31 | ih.ever_updated_a_table = false; 32 | 33 | // Info about the current engine... 34 | // Note that, when the engine is restarted, hub must call reset_engine_info() to fix these. A bit lame. 35 | ih.engine_start_time = performance.now(); 36 | ih.engine_sent_info = false; 37 | ih.engine_sent_q = false; 38 | ih.engine_sent_errors = false; 39 | ih.error_time = 0; 40 | ih.error_log = ""; 41 | ih.next_vms_order_int = 1; 42 | 43 | return ih; 44 | } 45 | 46 | let info_misc_props = { 47 | 48 | reset_engine_info: function() { 49 | this.engine_start_time = performance.now(); 50 | this.engine_sent_info = false; 51 | this.engine_sent_q = false; 52 | this.engine_sent_errors = false; 53 | this.error_time = 0; 54 | this.error_log = ""; 55 | this.next_vms_order_int = 1; 56 | }, 57 | 58 | displaying_error_log: function() { 59 | 60 | // Recent error... 61 | 62 | if (this.engine_sent_errors && performance.now() - this.error_time < 10000) { 63 | return true; 64 | } 65 | 66 | // Engine hasn't yet sent info, and was recently started... 67 | 68 | if (!this.engine_sent_info) { 69 | if (performance.now() - this.engine_start_time < 5000) { 70 | return true; 71 | } 72 | } 73 | 74 | // We have never updated a table (meaning we never received useful info from an engine) 75 | // and we aren't displaying API info... 76 | 77 | if (!this.ever_updated_a_table && !config.looker_api) { 78 | return true; 79 | } 80 | 81 | return false; 82 | }, 83 | }; 84 | 85 | let info_receiver_props = { 86 | 87 | err_receive: function(s) { 88 | 89 | if (typeof s !== "string") { 90 | return; 91 | } 92 | 93 | if (this.error_log.length > 50000) { 94 | return; 95 | } 96 | 97 | let s_low = s.toLowerCase(); 98 | 99 | if (s_low.includes("warning") || s_low.includes("error") || s_low.includes("unknown") || s_low.includes("failed") || s_low.includes("exception")) { 100 | this.engine_sent_errors = true; 101 | this.error_log += `${s}
`; 102 | this.error_time = performance.now(); 103 | } else { 104 | this.error_log += `${s}
`; 105 | } 106 | }, 107 | 108 | receive: function(engine, search, s) { 109 | 110 | let node = search.node; 111 | 112 | if (typeof s !== "string" || !node || node.destroyed) { 113 | return; 114 | } 115 | 116 | let board = node.board; 117 | 118 | if (s.startsWith("info") && s.includes(" pv ") && ((!s.includes("lowerbound") && !s.includes("upperbound")) || config.accept_bounds)) { 119 | 120 | if (config.log_info_lines) Log("< " + s); 121 | 122 | // info depth 8 seldepth 31 time 3029 nodes 23672 score cp 27 wdl 384 326 290 nps 7843 tbhits 0 multipv 1 123 | // pv d2d4 g8f6 c2c4 e7e6 g1f3 d7d5 b1c3 f8b4 c1g5 d5c4 e2e4 c7c5 f1c4 h7h6 g5f6 d8f6 e1h1 c5d4 e4e5 f6d8 c3e4 124 | 125 | let infovals = InfoValMany(s, ["pv", "cp", "mate", "multipv", "nodes", "nps", "time", "depth", "seldepth", "tbhits"]); 126 | 127 | let tmp; 128 | let move_info; 129 | let move = infovals["pv"]; 130 | move = board.c960_castling_converter(move); 131 | 132 | if (node.table.moveinfo[move] && !node.table.moveinfo[move].__ghost) { // We already have move info for this move. 133 | move_info = node.table.moveinfo[move]; 134 | } else { // We don't. 135 | if (board.illegal(move)) { 136 | if (config.log_illegal_moves) { 137 | Log(`INVALID / ILLEGAL MOVE RECEIVED: ${move}`); 138 | } 139 | return; 140 | } 141 | move_info = NewInfo(board, move); 142 | node.table.moveinfo[move] = move_info; 143 | } 144 | 145 | let move_cycle_pre_update = move_info.cycle; 146 | let move_depth_pre_update = move_info.depth; 147 | 148 | // --------------------------------------------------------------------------------------------------------------------- 149 | 150 | if (!engine.leelaish) { 151 | move_info.clear_stats(); // The stats we get this way are all that the engine has, so clear everything. 152 | } 153 | move_info.leelaish = engine.leelaish; 154 | 155 | this.engine_sent_info = true; // After the move legality check; i.e. we want REAL info 156 | this.ever_updated_a_table = true; 157 | node.table.version++; 158 | node.table.limit = search.limit; 159 | 160 | move_info.cycle = this.engine_cycle; 161 | move_info.__touched = true; 162 | 163 | // --------------------------------------------------------------------------------------------------------------------- 164 | 165 | let did_set_q_from_mate = false; 166 | 167 | tmp = parseInt(infovals["cp"], 10); 168 | if (Number.isNaN(tmp) === false) { 169 | move_info.cp = tmp; 170 | if (this.engine_sent_q === false) { 171 | move_info.q = QfromPawns(tmp / 100); // Potentially overwritten later by the better QfromWDL() 172 | } 173 | move_info.mate = 0; // Engines will send one of cp or mate, so mate gets reset when receiving cp 174 | } 175 | 176 | tmp = parseInt(infovals["mate"], 10); 177 | if (Number.isNaN(tmp) === false) { 178 | move_info.mate = tmp; 179 | if (tmp !== 0) { 180 | move_info.q = tmp > 0 ? 1 : -1; 181 | move_info.cp = tmp > 0 ? 32000 : -32000; 182 | did_set_q_from_mate = true; 183 | } 184 | } 185 | 186 | tmp = parseInt(infovals["multipv"], 10); 187 | if (Number.isNaN(tmp) === false) { 188 | move_info.multipv = tmp; 189 | if (tmp === 1) { 190 | this.engine_subcycle++; 191 | } 192 | } else { 193 | this.engine_subcycle++; 194 | } 195 | move_info.subcycle = this.engine_subcycle; 196 | 197 | tmp = parseInt(infovals["nodes"], 10); 198 | if (Number.isNaN(tmp) === false) { 199 | move_info.uci_nodes = tmp; 200 | node.table.nodes = tmp; 201 | } 202 | 203 | tmp = parseInt(infovals["nps"], 10); 204 | if (Number.isNaN(tmp) === false) { 205 | node.table.nps = tmp; // Note this is stored in the node.table, not the move_info 206 | } 207 | 208 | tmp = parseInt(infovals["time"], 10); 209 | if (Number.isNaN(tmp) === false) { 210 | node.table.time = tmp; // Note this is stored in the node.table, not the move_info 211 | } 212 | 213 | tmp = parseInt(infovals["tbhits"], 10); 214 | if (Number.isNaN(tmp) === false) { 215 | node.table.tbhits = tmp; // Note this is stored in the node.table, not the move_info 216 | } 217 | 218 | tmp = parseInt(infovals["depth"], 10); 219 | if (Number.isNaN(tmp) === false) { 220 | move_info.depth = tmp; 221 | } 222 | 223 | tmp = parseInt(infovals["seldepth"], 10); 224 | if (Number.isNaN(tmp) === false) { 225 | move_info.seldepth = tmp; 226 | } 227 | 228 | move_info.wdl = InfoWDL(s); 229 | if (this.engine_sent_q === false && !did_set_q_from_mate && Array.isArray(move_info.wdl)) { 230 | move_info.q = QfromWDL(move_info.wdl); 231 | } 232 | 233 | // If the engine isn't respecting Chess960 castling format, the PV 234 | // may contain old-fashioned castling moves... 235 | 236 | let new_pv = InfoPV(s); 237 | C960_PV_Converter(new_pv, board); 238 | 239 | if (CompareArrays(new_pv, move_info.pv) === false) { 240 | if (!board.sequence_illegal(new_pv)) { 241 | if (move_cycle_pre_update === move_info.cycle 242 | && ArrayStartsWith(move_info.pv, new_pv) 243 | && move_depth_pre_update >= move_info.depth - 1 244 | ) { 245 | // Skip the update. This partially mitigates Stockfish sending unresolved PVs. 246 | // We don't skip the update if the old PV is too old - issue noticed by Nagisa. 247 | } else { 248 | move_info.set_pv(new_pv); 249 | } 250 | } else { 251 | move_info.set_pv([move]); 252 | } 253 | } 254 | 255 | } else if (s.startsWith("info string") && !s.includes("NNUE evaluation")) { 256 | 257 | if (config.log_info_lines) Log("< " + s); 258 | 259 | // info string d2d4 (293 ) N: 12005 (+169) (P: 22.38%) (WL: 0.09480) (D: 0.326) 260 | // (M: 7.4) (Q: 0.09480) (U: 0.01211) (Q+U: 0.10691) (V: 0.0898) 261 | 262 | // Ceres has been known to send these in Euro decimal format e.g. Q: 0,094 263 | // We'll have to replace all commas... 264 | 265 | s = ReplaceAll(s, ",", "."); 266 | 267 | let infovals = InfoValMany(s, ["string", "N:", "(D:", "(U:", "(Q+U:", "(S:", "(P:", "(Q:", "(V:", "(M:"]); 268 | 269 | let tmp; 270 | let move_info; 271 | let move = infovals["string"]; 272 | 273 | if (move === "node") { // Mostly ignore these lines, but... 274 | this.next_vms_order_int = 1; // ...use them to note that the VerboseMoveStats have completed. A bit sketchy? 275 | tmp = parseInt(infovals["N:"], 10); 276 | if (Number.isNaN(tmp) === false) { 277 | node.table.nodes = tmp; // ...and use this line to ensure a valid nodes count for the table. (Mostly helps with Ceres.) 278 | } 279 | return; 280 | } 281 | 282 | move = board.c960_castling_converter(move); 283 | 284 | if (node.table.moveinfo[move] && !node.table.moveinfo[move].__ghost) { // We already have move info for this move. 285 | move_info = node.table.moveinfo[move]; 286 | } else { // We don't. 287 | if (board.illegal(move)) { 288 | if (config.log_illegal_moves) { 289 | Log(`INVALID / ILLEGAL MOVE RECEIVED: ${move}`); 290 | } 291 | return; 292 | } 293 | move_info = NewInfo(board, move); 294 | node.table.moveinfo[move] = move_info; 295 | } 296 | 297 | // --------------------------------------------------------------------------------------------------------------------- 298 | 299 | engine.leelaish = true; // Note this isn't the main way engine.leelaish gets set (because reasons) 300 | move_info.leelaish = true; 301 | 302 | this.engine_sent_info = true; // After the move legality check; i.e. we want REAL info 303 | this.ever_updated_a_table = true; 304 | node.table.version++; 305 | node.table.limit = search.limit; 306 | 307 | // move_info.cycle = this.engine_cycle; // No... we get VMS lines even when excluded by searchmoves. 308 | // move_info.subcycle = this.engine_subcycle; 309 | move_info.__touched = true; 310 | 311 | // --------------------------------------------------------------------------------------------------------------------- 312 | 313 | move_info.vms_order = this.next_vms_order_int++; 314 | 315 | tmp = parseInt(infovals["N:"], 10); 316 | if (Number.isNaN(tmp) === false) { 317 | move_info.n = tmp; 318 | } 319 | 320 | tmp = parseFloat(infovals["(U:"]); 321 | if (Number.isNaN(tmp) === false) { 322 | move_info.u = tmp; 323 | } 324 | 325 | tmp = parseFloat(infovals["(Q+U:"]); // Q+U, old name for S 326 | if (Number.isNaN(tmp) === false) { 327 | move_info.s = tmp; 328 | } 329 | 330 | tmp = parseFloat(infovals["(S:"]); 331 | if (Number.isNaN(tmp) === false) { 332 | move_info.s = tmp; 333 | } 334 | 335 | tmp = parseFloat(infovals["(P:"]); // P, parseFloat will ignore the trailing % 336 | if (Number.isNaN(tmp) === false) { 337 | move_info.p = tmp; 338 | } 339 | 340 | tmp = parseFloat(infovals["(Q:"]); 341 | if (Number.isNaN(tmp) === false) { 342 | this.engine_sent_q = true; 343 | move_info.q = tmp; 344 | } 345 | 346 | tmp = parseFloat(infovals["(V:"]); 347 | if (Number.isNaN(tmp) === false) { 348 | move_info.v = tmp; 349 | } else { 350 | move_info.v = null; // V sometimes is -.----- (we used to not do anything here, preserving any old (but confusing) value) 351 | } 352 | 353 | tmp = parseFloat(infovals["(M:"]); 354 | if (Number.isNaN(tmp) === false) { 355 | move_info.m = tmp; 356 | } else { 357 | move_info.m = null; // M sometimes is -.----- (we used to not do anything here, preserving any old (but confusing) value) 358 | } 359 | 360 | } else if (s.startsWith("info") && s.includes(" pv ") && (s.includes("lowerbound") || s.includes("upperbound"))) { 361 | 362 | if (config.log_info_lines) Log("< " + s); 363 | 364 | let infovals = InfoValMany(s, ["pv", "multipv"]); 365 | 366 | let tmp; 367 | let move_info; 368 | let move = infovals["pv"]; 369 | move = board.c960_castling_converter(move); 370 | 371 | if (node.table.moveinfo[move] && !node.table.moveinfo[move].__ghost) { // We already have move info for this move. 372 | move_info = node.table.moveinfo[move]; 373 | } 374 | 375 | if (move_info) { 376 | tmp = parseInt(infovals["multipv"], 10); 377 | if (Number.isNaN(tmp) === false) { 378 | move_info.multipv = tmp; 379 | move_info.subcycle = this.engine_subcycle; 380 | } 381 | } 382 | 383 | } else { 384 | 385 | if (config.log_info_lines && config.log_useless_info) Log("< " + s); 386 | 387 | } 388 | }, 389 | }; 390 | -------------------------------------------------------------------------------- /files/src/renderer/81_arrows.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | let arrow_props = { 4 | 5 | draw_arrows: function(node, specific_source, show_move) { // If not nullish, specific_source is a Point() and show_move is a string 6 | 7 | // Function is responsible for updating the one_click_moves array. 8 | 9 | for (let x = 0; x < 8; x++) { 10 | for (let y = 0; y < 8; y++) { 11 | this.one_click_moves[x][y] = null; 12 | } 13 | } 14 | 15 | if (!config.arrows_enabled || !node || node.destroyed) { 16 | return; 17 | } 18 | 19 | let full_list = SortedMoveInfo(node); 20 | 21 | if (full_list.length === 0) { // Keep this test early so we can assume full_list[0] exists later. 22 | return; 23 | } 24 | 25 | let best_info = full_list[0]; // Note that, since we may filter the list, it might not contain best_info later. 26 | 27 | let info_list = []; 28 | let arrows = []; 29 | let heads = []; 30 | 31 | let mode; 32 | let show_move_was_forced = false; // Will become true if the show_move is only in the list because of the show_move arg 33 | let show_move_head = null; 34 | 35 | if (specific_source) { 36 | mode = "specific"; 37 | } else if (full_list[0].__ghost) { 38 | mode = "ghost"; 39 | } else if (full_list[0].__touched === false) { 40 | mode = "untouched"; 41 | } else if (full_list[0].leelaish === false) { 42 | mode = "ab"; 43 | } else { 44 | mode = "normal"; 45 | } 46 | 47 | switch (mode) { 48 | 49 | case "normal": 50 | 51 | info_list = full_list; 52 | break; 53 | 54 | case "ab": 55 | 56 | for (let info of full_list) { 57 | if (info.__touched && info.subcycle >= full_list[0].subcycle) { 58 | info_list.push(info); 59 | } else if (info.move === show_move) { 60 | info_list.push(info); 61 | show_move_was_forced = true; 62 | } 63 | } 64 | break; 65 | 66 | case "ghost": 67 | 68 | for (let info of full_list) { 69 | if (info.__ghost) { 70 | info_list.push(info); 71 | } else if (info.move === show_move) { 72 | info_list.push(info); 73 | show_move_was_forced = true; 74 | } 75 | } 76 | break; 77 | 78 | case "untouched": 79 | 80 | for (let info of full_list) { 81 | if (info.move === show_move) { 82 | info_list.push(info); 83 | show_move_was_forced = true; 84 | } 85 | } 86 | break; 87 | 88 | case "specific": 89 | 90 | for (let info of full_list) { 91 | if (info.move.slice(0, 2) === specific_source.s) { 92 | info_list.push(info); 93 | } 94 | } 95 | break; 96 | 97 | } 98 | 99 | // ------------------------------------------------------------------------------------------------------------ 100 | 101 | for (let i = 0; i < info_list.length; i++) { 102 | 103 | let loss = 0; 104 | 105 | if (typeof best_info.q === "number" && typeof info_list[i].q === "number") { 106 | loss = best_info.value() - info_list[i].value(); 107 | } 108 | 109 | let ok = true; 110 | 111 | // Filter for normal (Leelaish) mode... 112 | 113 | if (mode === "normal") { 114 | 115 | if (config.arrow_filter_type === "top") { 116 | if (i !== 0) { 117 | ok = false; 118 | } 119 | } 120 | 121 | if (config.arrow_filter_type === "N") { 122 | if (typeof info_list[i].n !== "number" || info_list[i].n === 0) { 123 | ok = false; 124 | } else { 125 | let n_fraction = info_list[i].n / node.table.nodes; 126 | if (n_fraction < config.arrow_filter_value) { 127 | ok = false; 128 | } 129 | } 130 | } 131 | 132 | // Moves proven to lose... 133 | 134 | if (typeof info_list[i].u === "number" && info_list[i].u === 0 && info_list[i].value() === 0) { 135 | if (config.arrow_filter_type !== "all") { 136 | ok = false; 137 | } 138 | } 139 | 140 | // If the show_move would be filtered out, note that fact... 141 | 142 | if (!ok && info_list[i].move === show_move) { 143 | show_move_was_forced = true; 144 | } 145 | } 146 | 147 | // Filter for ab mode... 148 | // Note that we don't set show_move_was_forced for ab mode. 149 | // If it wasn't already set, then we have good info for this move. 150 | 151 | if (mode === "ab") { 152 | if (loss >= config.ab_filter_threshold) { 153 | ok = false; 154 | } 155 | } 156 | 157 | // Go ahead, if the various tests don't filter the move out... 158 | 159 | if (ok || i === 0 || info_list[i].move === show_move) { 160 | 161 | let [x1, y1] = XY(info_list[i].move.slice(0, 2)); 162 | let [x2, y2] = XY(info_list[i].move.slice(2, 4)); 163 | 164 | let colour; 165 | 166 | if (info_list[i].move === show_move && config.next_move_unique_colour) { 167 | colour = config.actual_move_colour; 168 | } else if (info_list[i].move === show_move && show_move_was_forced) { 169 | colour = config.terrible_colour; 170 | } else if (info_list[i].__touched === false) { 171 | colour = config.terrible_colour; 172 | } else if (info_list[i] === best_info) { 173 | colour = config.best_colour; 174 | } else if (loss < config.bad_move_threshold) { 175 | colour = config.good_colour; 176 | } else if (loss < config.terrible_move_threshold) { 177 | colour = config.bad_colour; 178 | } else { 179 | colour = config.terrible_colour; 180 | } 181 | 182 | let x_head_adjustment = 0; // Adjust head of arrow for castling moves... 183 | let normal_castling_flag = false; 184 | 185 | if (node.board && node.board.colour(Point(x1, y1)) === node.board.colour(Point(x2, y2))) { 186 | 187 | // So the move is a castling move (reminder: as of 1.1.6 castling format is king-onto-rook). 188 | 189 | if (node.board.normalchess) { 190 | normal_castling_flag = true; // ...and we are playing normal Chess (not 960). 191 | } 192 | 193 | if (x2 > x1) { 194 | x_head_adjustment = normal_castling_flag ? -1 : -0.5; 195 | } else { 196 | x_head_adjustment = normal_castling_flag ? 2 : 0.5; 197 | } 198 | } 199 | 200 | arrows.push({ 201 | colour: colour, 202 | x1: x1, 203 | y1: y1, 204 | x2: x2 + x_head_adjustment, 205 | y2: y2, 206 | info: info_list[i] 207 | }); 208 | 209 | // If there is no one_click_move set for the target square, then set it 210 | // and also set an arrowhead to be drawn later. 211 | 212 | if (normal_castling_flag) { 213 | if (!this.one_click_moves[x2 + x_head_adjustment][y2]) { 214 | heads.push({ 215 | colour: colour, 216 | x2: x2 + x_head_adjustment, 217 | y2: y2, 218 | info: info_list[i] 219 | }); 220 | this.one_click_moves[x2 + x_head_adjustment][y2] = info_list[i].move; 221 | if (info_list[i].move === show_move) { 222 | show_move_head = heads[heads.length - 1]; 223 | } 224 | } 225 | } else { 226 | if (!this.one_click_moves[x2][y2]) { 227 | heads.push({ 228 | colour: colour, 229 | x2: x2 + x_head_adjustment, 230 | y2: y2, 231 | info: info_list[i] 232 | }); 233 | this.one_click_moves[x2][y2] = info_list[i].move; 234 | if (info_list[i].move === show_move) { 235 | show_move_head = heads[heads.length - 1]; 236 | } 237 | } 238 | } 239 | } 240 | } 241 | 242 | // It looks best if the longest arrows are drawn underneath. Manhattan distance is good enough. 243 | // For the sake of displaying the best pawn promotion (of the 4 possible), sort ties are broken 244 | // by node counts, with lower drawn first. [Eh, what about Stockfish? Meh, it doesn't affect 245 | // the heads, merely the colour of the lines, so it's not a huge problem I think.] 246 | 247 | arrows.sort((a, b) => { 248 | if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) < Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) { 249 | return 1; 250 | } 251 | if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) > Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) { 252 | return -1; 253 | } 254 | if (a.info.n < b.info.n) { 255 | return -1; 256 | } 257 | if (a.info.n > b.info.n) { 258 | return 1; 259 | } 260 | return 0; 261 | }); 262 | 263 | boardctx.lineWidth = config.arrow_width; 264 | boardctx.textAlign = "center"; 265 | boardctx.textBaseline = "middle"; 266 | boardctx.font = config.board_font; 267 | 268 | for (let o of arrows) { 269 | 270 | let cc1 = CanvasCoords(o.x1, o.y1); 271 | let cc2 = CanvasCoords(o.x2, o.y2); 272 | 273 | if (o.info.move === show_move && config.next_move_outline) { // Draw the outline at the layer just below the actual arrow. 274 | boardctx.strokeStyle = "black"; 275 | boardctx.fillStyle = "black"; 276 | boardctx.lineWidth = config.arrow_width + 4; 277 | boardctx.beginPath(); 278 | boardctx.moveTo(cc1.cx, cc1.cy); 279 | boardctx.lineTo(cc2.cx, cc2.cy); 280 | boardctx.stroke(); 281 | boardctx.lineWidth = config.arrow_width; 282 | 283 | if (show_move_head) { // This is the best layer to draw the head outline. 284 | boardctx.beginPath(); 285 | boardctx.arc(cc2.cx, cc2.cy, config.arrowhead_radius + 2, 0, 2 * Math.PI); 286 | boardctx.fill(); 287 | } 288 | } 289 | 290 | boardctx.strokeStyle = o.colour; 291 | boardctx.fillStyle = o.colour; 292 | boardctx.beginPath(); 293 | boardctx.moveTo(cc1.cx, cc1.cy); 294 | boardctx.lineTo(cc2.cx, cc2.cy); 295 | boardctx.stroke(); 296 | } 297 | 298 | for (let o of heads) { 299 | 300 | let cc2 = CanvasCoords(o.x2, o.y2); 301 | 302 | boardctx.fillStyle = o.colour; 303 | boardctx.beginPath(); 304 | boardctx.arc(cc2.cx, cc2.cy, config.arrowhead_radius, 0, 2 * Math.PI); 305 | boardctx.fill(); 306 | boardctx.fillStyle = "black"; 307 | 308 | let s = "?"; 309 | 310 | switch (config.arrowhead_type) { 311 | case 0: 312 | s = o.info.value_string(0, config.ev_pov); 313 | if (s === "100" && o.info.q < 1.0) { 314 | s = "99"; // Don't round up to 100. 315 | } 316 | break; 317 | case 1: 318 | if (node.table.nodes > 0) { 319 | s = (100 * o.info.n / node.table.nodes).toFixed(0); 320 | } 321 | break; 322 | case 2: 323 | if (o.info.p > 0) { 324 | s = o.info.p.toFixed(0); 325 | } 326 | break; 327 | case 3: 328 | s = o.info.multipv; 329 | break; 330 | case 4: 331 | if (typeof o.info.m === "number") { 332 | s = o.info.m.toFixed(0); 333 | } 334 | break; 335 | default: 336 | s = "!"; 337 | break; 338 | } 339 | 340 | if (o.info.__touched === false) { 341 | s = "?"; 342 | } 343 | 344 | if (show_move_was_forced && o.info.move === show_move) { 345 | s = "?"; 346 | } 347 | 348 | boardctx.fillText(s, cc2.cx, cc2.cy + 1); 349 | } 350 | 351 | draw_arrows_last_mode = mode; // For debugging only. 352 | }, 353 | 354 | // ---------------------------------------------------------------------------------------------------------- 355 | // We have a special function for the book explorer mode. Explorer mode is very nicely isolated from the rest 356 | // of the app. The info_list here is just a list of objects each containing only "move" and "weight" - where 357 | // the weights have been normalised to the 0-1 scale and the list has been sorted. 358 | // 359 | // Note that info_list here MUST NOT BE MODIFIED. 360 | 361 | draw_explorer_arrows: function(node, info_list, specific_source) { // If not nullish, specific_source is a Point() 362 | 363 | for (let x = 0; x < 8; x++) { 364 | for (let y = 0; y < 8; y++) { 365 | this.one_click_moves[x][y] = null; 366 | } 367 | } 368 | 369 | if (!node || node.destroyed) { 370 | return; 371 | } 372 | 373 | let arrows = []; 374 | let heads = []; 375 | 376 | for (let i = 0; i < info_list.length; i++) { 377 | 378 | if (specific_source && specific_source.s !== info_list[i].move.slice(0, 2)) { 379 | continue; 380 | } 381 | 382 | let [x1, y1] = XY(info_list[i].move.slice(0, 2)); 383 | let [x2, y2] = XY(info_list[i].move.slice(2, 4)); 384 | 385 | let colour = i === 0 ? config.best_colour : config.good_colour; 386 | 387 | let x_head_adjustment = 0; // Adjust head of arrow for castling moves... 388 | let normal_castling_flag = false; 389 | 390 | if (node.board && node.board.colour(Point(x1, y1)) === node.board.colour(Point(x2, y2))) { 391 | 392 | if (node.board.normalchess) { 393 | normal_castling_flag = true; // ...and we are playing normal Chess (not 960). 394 | } 395 | 396 | if (x2 > x1) { 397 | x_head_adjustment = normal_castling_flag ? -1 : -0.5; 398 | } else { 399 | x_head_adjustment = normal_castling_flag ? 2 : 0.5; 400 | } 401 | } 402 | 403 | arrows.push({ 404 | colour: colour, 405 | x1: x1, 406 | y1: y1, 407 | x2: x2 + x_head_adjustment, 408 | y2: y2, 409 | info: info_list[i] 410 | }); 411 | 412 | // If there is no one_click_move set for the target square, then set it 413 | // and also set an arrowhead to be drawn later. 414 | 415 | if (normal_castling_flag) { 416 | if (!this.one_click_moves[x2 + x_head_adjustment][y2]) { 417 | heads.push({ 418 | colour: colour, 419 | x2: x2 + x_head_adjustment, 420 | y2: y2, 421 | info: info_list[i] 422 | }); 423 | this.one_click_moves[x2 + x_head_adjustment][y2] = info_list[i].move; 424 | } 425 | } else { 426 | if (!this.one_click_moves[x2][y2]) { 427 | heads.push({ 428 | colour: colour, 429 | x2: x2 + x_head_adjustment, 430 | y2: y2, 431 | info: info_list[i] 432 | }); 433 | this.one_click_moves[x2][y2] = info_list[i].move; 434 | } 435 | } 436 | } 437 | 438 | arrows.sort((a, b) => { 439 | if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) < Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) { 440 | return 1; 441 | } 442 | if (Math.abs(a.x2 - a.x1) + Math.abs(a.y2 - a.y1) > Math.abs(b.x2 - b.x1) + Math.abs(b.y2 - b.y1)) { 443 | return -1; 444 | } 445 | return 0; 446 | }); 447 | 448 | boardctx.lineWidth = config.arrow_width; 449 | boardctx.textAlign = "center"; 450 | boardctx.textBaseline = "middle"; 451 | boardctx.font = config.board_font; 452 | 453 | for (let o of arrows) { 454 | 455 | let cc1 = CanvasCoords(o.x1, o.y1); 456 | let cc2 = CanvasCoords(o.x2, o.y2); 457 | 458 | boardctx.strokeStyle = o.colour; 459 | boardctx.fillStyle = o.colour; 460 | boardctx.beginPath(); 461 | boardctx.moveTo(cc1.cx, cc1.cy); 462 | boardctx.lineTo(cc2.cx, cc2.cy); 463 | boardctx.stroke(); 464 | } 465 | 466 | for (let o of heads) { 467 | 468 | let cc2 = CanvasCoords(o.x2, o.y2); 469 | 470 | boardctx.fillStyle = o.colour; 471 | boardctx.beginPath(); 472 | boardctx.arc(cc2.cx, cc2.cy, config.arrowhead_radius, 0, 2 * Math.PI); 473 | boardctx.fill(); 474 | boardctx.fillStyle = "black"; 475 | 476 | let s = "?"; 477 | 478 | if (typeof o.info.weight === "number") { 479 | s = (100 * o.info.weight).toFixed(0); 480 | } 481 | 482 | boardctx.fillText(s, cc2.cx, cc2.cy + 1); 483 | } 484 | } 485 | }; 486 | 487 | 488 | 489 | // For debugging... 490 | let draw_arrows_last_mode = null; 491 | -------------------------------------------------------------------------------- /files/src/renderer/82_infobox.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | let infobox_props = { 4 | 5 | draw_infobox: function(node, mouse_point, active_square, active_colour, hoverdraw_div, allow_inactive_focus, lookup_object) { 6 | 7 | let searchmoves = node.searchmoves; 8 | 9 | if (this.displaying_error_log()) { 10 | infobox.innerHTML = this.error_log; 11 | this.last_drawn_version = null; 12 | return; 13 | } 14 | 15 | if (!node || node.destroyed) { 16 | return; 17 | } 18 | 19 | let info_list; 20 | 21 | if (node.terminal_reason()) { 22 | info_list = []; 23 | } else { 24 | info_list = SortedMoveInfo(node); 25 | } 26 | 27 | // A lookup_object should always have type (string) and moves (object). 28 | 29 | let ltype = lookup_object ? lookup_object.type : null; 30 | let lookup_moves = lookup_object ? lookup_object.moves : null; 31 | 32 | // If we are using an online API, and the list has some "untouched" info, we 33 | // may be able to sort them using the API info. 34 | 35 | if (ltype === "chessdbcn" || ltype === "lichess_masters" || ltype === "lichess_plebs") { 36 | 37 | let touched_list = []; 38 | let untouched_list = []; 39 | 40 | for (let info of info_list) { 41 | if (info.__touched) { 42 | touched_list.push(info); 43 | } else { 44 | untouched_list.push(info); 45 | } 46 | } 47 | 48 | const a_is_best = -1; 49 | const b_is_best = 1; 50 | 51 | untouched_list.sort((a, b) => { 52 | if (lookup_moves[a.move] && !lookup_moves[b.move]) return a_is_best; 53 | if (!lookup_moves[a.move] && lookup_moves[b.move]) return b_is_best; 54 | if (!lookup_moves[a.move] && !lookup_moves[b.move]) return 0; 55 | return lookup_moves[b.move].sort_score() - lookup_moves[a.move].sort_score(); 56 | }); 57 | 58 | info_list = touched_list.concat(untouched_list); 59 | } 60 | 61 | let best_subcycle = info_list.length > 0 ? info_list[0].subcycle : 0; 62 | if (best_subcycle === 0) { // Because all info was autopopulated 63 | best_subcycle = -1; // Causes all info to be gray 64 | } 65 | 66 | if (typeof config.max_info_lines === "number" && config.max_info_lines > 0) { // Hidden option, request of rwbc 67 | info_list = info_list.slice(0, config.max_info_lines); 68 | } 69 | 70 | // We might be highlighting some div... 71 | 72 | let highlight_move = null; 73 | let highlight_class = null; 74 | 75 | // We'll highlight it if it's a valid OCM *and* clicking there now would make it happen... 76 | 77 | if (mouse_point && this.one_click_moves[mouse_point.x][mouse_point.y]) { 78 | if (!active_square || this.one_click_moves[mouse_point.x][mouse_point.y].slice(0, 2) === active_square.s) { 79 | highlight_move = this.one_click_moves[mouse_point.x][mouse_point.y]; 80 | highlight_class = "ocm_highlight"; 81 | } 82 | } 83 | 84 | if (typeof hoverdraw_div === "number" && hoverdraw_div >= 0 && hoverdraw_div < info_list.length) { 85 | highlight_move = info_list[hoverdraw_div].move; 86 | highlight_class = "hover_highlight"; 87 | } 88 | 89 | // We cannot skip the draw if... 90 | 91 | let no_skip_reasons = []; 92 | 93 | if (node.id !== this.last_drawn_node_id) no_skip_reasons.push("node"); 94 | if (node.table.version !== this.last_drawn_version) no_skip_reasons.push("table version"); 95 | if (highlight_move !== this.last_drawn_highlight_move) no_skip_reasons.push("highlight move"); 96 | if (highlight_class !== this.last_drawn_highlight_class) no_skip_reasons.push("highlight class"); 97 | if (info_list.length !== this.last_drawn_length) no_skip_reasons.push("info list length"); 98 | if (allow_inactive_focus !== this.last_drawn_allow_inactive_focus) no_skip_reasons.push("allow inactive focus"); 99 | if (CompareArrays(searchmoves, this.last_drawn_searchmoves) === false) no_skip_reasons.push("searchmoves"); 100 | if (lookup_object !== this.last_drawn_lookup_object) no_skip_reasons.push("lookup object"); 101 | 102 | draw_infobox_no_skip_reasons = no_skip_reasons.join(", "); // For debugging only. 103 | 104 | if (no_skip_reasons.length === 0) { 105 | draw_infobox_total_skips++; 106 | return; 107 | } 108 | 109 | this.last_drawn_node_id = node.id; 110 | this.last_drawn_version = node.table.version; 111 | this.last_drawn_highlight_move = highlight_move; 112 | this.last_drawn_highlight_class = highlight_class; 113 | this.last_drawn_length = info_list.length; 114 | this.last_drawn_allow_inactive_focus = allow_inactive_focus; 115 | this.last_drawn_searchmoves = Array.from(searchmoves); 116 | this.last_drawn_lookup_object = lookup_object; 117 | 118 | this.info_clickers = []; 119 | this.info_clickers_node_id = node.id; 120 | 121 | let substrings = []; 122 | let clicker_index = 0; 123 | let div_index = 0; 124 | 125 | for (let info of info_list) { 126 | 127 | // The div containing the PV etc... 128 | 129 | let divclass = "infoline"; 130 | 131 | if (info.subcycle !== best_subcycle && !config.never_grayout_infolines) { 132 | divclass += " " + "gray"; 133 | } 134 | 135 | if (info.move === highlight_move) { 136 | divclass += " " + highlight_class; 137 | } 138 | 139 | substrings.push(`
`); 140 | 141 | // The "focus" button... 142 | 143 | if (config.searchmoves_buttons) { 144 | if (searchmoves.includes(info.move)) { 145 | substrings.push(`${config.focus_on_text} `); 146 | } else { 147 | if (allow_inactive_focus) { 148 | substrings.push(`${config.focus_off_text} `); 149 | } 150 | } 151 | } 152 | 153 | // The value... 154 | 155 | let value_string = "?"; 156 | if (config.show_cp) { 157 | if (typeof info.mate === "number" && info.mate !== 0) { 158 | value_string = info.mate_string(config.cp_pov); 159 | } else { 160 | value_string = info.cp_string(config.cp_pov); 161 | } 162 | } else { 163 | value_string = info.value_string(1, config.ev_pov); 164 | if (value_string !== "?") { 165 | value_string += "%"; 166 | } 167 | } 168 | 169 | if (info.subcycle === best_subcycle || config.never_grayout_infolines) { 170 | substrings.push(`${value_string} `); 171 | } else { 172 | substrings.push(`${value_string} `); 173 | } 174 | 175 | // The PV... 176 | 177 | let colour = active_colour; 178 | let movenum = node.board.fullmove; // Only matters for config.infobox_pv_move_numbers 179 | let nice_pv = info.nice_pv(); 180 | 181 | for (let i = 0; i < nice_pv.length; i++) { 182 | let spanclass = ""; 183 | if (info.subcycle === best_subcycle || config.never_grayout_infolines) { 184 | spanclass = colour === "w" ? "white" : "pink"; 185 | } 186 | if (nice_pv[i].includes("O-O")) { 187 | spanclass += (spanclass.length > 0) ? " nobr" : "nobr"; 188 | } 189 | 190 | let numstring = ""; 191 | if (config.infobox_pv_move_numbers) { 192 | if (colour === "w") { 193 | numstring = `${movenum}. `; 194 | } else if (colour === "b" && i === 0) { 195 | numstring = `${movenum}... `; 196 | } 197 | } 198 | 199 | substrings.push(`${numstring}${nice_pv[i]} `); 200 | this.info_clickers.push({ 201 | move: info.pv[i], 202 | is_start: i === 0, 203 | is_end: i === nice_pv.length - 1, 204 | }); 205 | colour = OppositeColour(colour); 206 | if (colour === "w") { 207 | movenum++; 208 | } 209 | } 210 | 211 | // The extra stats... 212 | 213 | let extra_stat_strings = []; 214 | 215 | if (info.__touched) { 216 | 217 | let stats_list = info.stats_list( 218 | { 219 | n: config.show_n, 220 | n_abs: config.show_n_abs, 221 | depth: config.show_depth, 222 | wdl: config.show_wdl, 223 | wdl_pov: config.wdl_pov, 224 | p: config.show_p, 225 | m: config.show_m, 226 | v: config.show_v, 227 | q: config.show_q, 228 | u: config.show_u, 229 | s: config.show_s, 230 | }, node.table.nodes); 231 | 232 | extra_stat_strings = extra_stat_strings.concat(stats_list); 233 | } 234 | 235 | if (config.looker_api) { 236 | let api_string = "API: ?"; 237 | if (ltype && lookup_moves) { 238 | let pov = null; 239 | if (ltype === "chessdbcn") { 240 | pov = config.cp_pov; 241 | } else if (ltype === "lichess_masters" || ltype === "lichess_plebs") { 242 | pov = config.ev_pov; 243 | } 244 | let o = lookup_moves[info.move]; 245 | if (typeof o === "object" && o !== null) { 246 | api_string = o.text(pov); 247 | } 248 | } 249 | extra_stat_strings.push(api_string); 250 | } 251 | 252 | if (extra_stat_strings.length > 0) { 253 | if (config.infobox_stats_newline) { 254 | substrings.push("
"); 255 | } 256 | substrings.push(`(${extra_stat_strings.join(', ')})`); 257 | } 258 | 259 | // Close the whole div... 260 | 261 | substrings.push("
"); 262 | 263 | } 264 | 265 | infobox.innerHTML = substrings.join(""); 266 | }, 267 | 268 | must_draw_infobox: function() { 269 | this.last_drawn_version = null; 270 | }, 271 | 272 | clickers_are_valid_for_node: function(node) { 273 | if (!node || !this.info_clickers_node_id) { 274 | return false; 275 | } 276 | return node.id === this.info_clickers_node_id; 277 | }, 278 | 279 | moves_from_click_n: function(n, desired_length = null) { 280 | 281 | if (typeof n !== "number" || Number.isNaN(n)) { 282 | return []; 283 | } 284 | 285 | if (!this.info_clickers || n < 0 || n >= this.info_clickers.length) { 286 | return []; 287 | } 288 | 289 | let move_list = []; 290 | 291 | // Work backwards until we get to the start of the line... 292 | 293 | for (let i = n; i >= 0; i--) { 294 | let object = this.info_clickers[i]; 295 | move_list.push(object.move); 296 | if (object.is_start) { 297 | break; 298 | } 299 | } 300 | 301 | move_list.reverse(); 302 | 303 | // If a PV length is specified, either truncate or extend as needed... 304 | 305 | if (typeof desired_length === "number") { 306 | if (move_list.length > desired_length) { 307 | move_list = move_list.slice(0, desired_length); 308 | } else if (move_list.length < desired_length) { 309 | for (let i = n + 1; i < this.info_clickers.length; i++) { 310 | let object = this.info_clickers[i]; 311 | if (object.is_start) { 312 | break; 313 | } 314 | move_list.push(object.move); // Note the different order of stataments compared to the above. 315 | if (move_list.length >= desired_length) { 316 | break; 317 | } 318 | } 319 | } 320 | } 321 | 322 | return move_list; 323 | }, 324 | 325 | }; 326 | 327 | 328 | 329 | // For debugging... 330 | let draw_infobox_total_skips = 0; 331 | let draw_infobox_no_skip_reasons = ""; 332 | -------------------------------------------------------------------------------- /files/src/renderer/83_statusbox.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function NewStatusHandler() { 4 | 5 | let sh = Object.create(null); 6 | 7 | sh.special_message = null; 8 | sh.special_message_class = "yellow"; 9 | sh.special_message_timeout = performance.now(); 10 | 11 | sh.set_special_message = function(s, css_class, duration) { 12 | if (!css_class) css_class = "yellow"; 13 | if (!duration) duration = 3000; 14 | this.special_message = s; 15 | this.special_message_class = css_class; 16 | this.special_message_timeout = performance.now() + duration; 17 | }; 18 | 19 | sh.draw_statusbox = function(node, engine, analysing_other, loading_message, book_is_loaded) { 20 | 21 | if (loading_message) { 22 | 23 | statusbox.innerHTML = `${loading_message} (abort?)`; 24 | 25 | } else if (config.show_engine_state) { 26 | 27 | let cl; 28 | let status; 29 | 30 | if (engine.search_running.node && engine.search_running === engine.search_desired) { 31 | cl = "green"; 32 | status = "running"; 33 | } else if (engine.search_running !== engine.search_desired) { 34 | cl = "yellow"; 35 | status = "desync"; 36 | } else { 37 | cl = "yellow"; 38 | status = "stopped"; 39 | } 40 | 41 | statusbox.innerHTML = 42 | `${status}, ` + 43 | `${config.behaviour}, ` + 44 | `${engine.last_send}`; 45 | 46 | } else if (!engine.ever_received_uciok) { 47 | 48 | statusbox.innerHTML = `Awaiting uciok from engine`; 49 | 50 | } else if (!engine.ever_received_readyok) { 51 | 52 | statusbox.innerHTML = `Awaiting readyok from engine`; 53 | 54 | } else if (this.special_message && performance.now() < this.special_message_timeout) { 55 | 56 | statusbox.innerHTML = `${this.special_message}`; 57 | 58 | } else if (engine.unresolved_stop_time && performance.now() - engine.unresolved_stop_time > 500) { 59 | 60 | statusbox.innerHTML = `${messages.desync}`; 61 | 62 | } else if (analysing_other) { 63 | 64 | statusbox.innerHTML = `Locked to ${analysing_other} (return?)`; 65 | 66 | } else if (node.terminal_reason()) { 67 | 68 | statusbox.innerHTML = `${node.terminal_reason()}`; 69 | 70 | } else if (!node || node.destroyed) { 71 | 72 | statusbox.innerHTML = `draw_statusbox - !node || node.destroyed`; 73 | 74 | } else { 75 | 76 | let status_string = ""; 77 | 78 | if (config.behaviour === "halt" && !engine.search_running.node) { 79 | status_string += `HALTED (go?) `; 80 | } else if (config.behaviour === "halt" && engine.search_running.node) { 81 | status_string += `HALTING... `; 82 | } else if (config.behaviour === "analysis_locked") { 83 | status_string += `Locked! `; 84 | } else if (config.behaviour === "play_white" && node.board.active !== "w") { 85 | status_string += `YOUR MOVE `; 86 | } else if (config.behaviour === "play_black" && node.board.active !== "b") { 87 | status_string += `YOUR MOVE `; 88 | } else if (config.behaviour === "self_play") { 89 | status_string += `Self-play! `; 90 | } else if (config.behaviour === "auto_analysis") { 91 | status_string += `Auto-eval! `; 92 | } else if (config.behaviour === "back_analysis") { 93 | status_string += `Back-eval! `; 94 | } else if (config.behaviour === "analysis_free") { 95 | if (hub.engine.sent_options.contempt !== undefined && hub.engine.sent_options.contempt !== "0") { 96 | status_string += `Contempt active! `; 97 | } else { 98 | status_string += `ANALYSIS (halt?) `; 99 | } 100 | } 101 | 102 | if (config.book_explorer) { 103 | 104 | let warn = book_is_loaded ? "" : " (No book loaded)"; 105 | status_string += `Book frequency arrows only!${warn}`; 106 | 107 | } else if (config.lichess_explorer) { 108 | 109 | let warn = (config.looker_api === "lichess_masters" || config.looker_api === "lichess_plebs") ? "" : " (API not selected)"; 110 | status_string += `Lichess frequency arrows only!${warn}`; 111 | 112 | } else { 113 | 114 | status_string += `${NString(node.table.nodes)} ${node.table.nodes === 1 ? "node" : "nodes"}`; 115 | status_string += `, ${DurationString(node.table.time)} (N/s: ${NString(node.table.nps)})`; 116 | if (engineconfig[engine.filepath].options["SyzygyPath"] || node.table.tbhits > 0) { 117 | status_string += `, ${NString(node.table.tbhits)} ${node.table.tbhits === 1 ? "tbhit" : "tbhits"}`; 118 | } 119 | status_string += ``; 120 | 121 | if (!engine.search_running.node && engine.search_completed.node === node) { 122 | 123 | let stoppedtext = ""; 124 | 125 | if (config.behaviour !== "halt") { 126 | stoppedtext = ` (stopped)`; 127 | } 128 | /* 129 | // The following doesn't make sense if a time limit rather than a move limit is in force. 130 | 131 | if (typeof engineconfig[engine.filepath].search_nodes === "number" && engineconfig[engine.filepath].search_nodes > 0) { 132 | if (node.table.nodes >= engineconfig[engine.filepath].search_nodes) { 133 | stoppedtext = ` (limit met)`; 134 | } 135 | } 136 | */ 137 | status_string += stoppedtext; 138 | } 139 | } 140 | 141 | statusbox.innerHTML = status_string; 142 | } 143 | }; 144 | 145 | return sh; 146 | } 147 | -------------------------------------------------------------------------------- /files/src/renderer/99_start.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Upon first run, hopefully the prefs directory exists by now 4 | // (I think the main process makes it...) 5 | 6 | config_io.create_if_needed(config); 7 | engineconfig_io.create_if_needed(engineconfig); 8 | custom_uci.create_if_needed(); 9 | 10 | Log(""); 11 | Log("======================================================================================================================================"); 12 | Log(`Nibbler startup at ${new Date().toUTCString()}`); 13 | 14 | let hub = NewHub(); 15 | hub.engine_start(config.path, true); 16 | 17 | if (load_err1) { 18 | hub.err_receive(`While loading config.json: ${load_err1}`); 19 | hub.err_receive(""); 20 | } else if (load_err2) { 21 | hub.err_receive(`While loading engines.json: ${load_err2}`); 22 | hub.err_receive(""); 23 | } else if (config.options) { 24 | alert(messages.engine_options_reset); 25 | config.args_unused = config.args; 26 | config.options_unused = config.options; 27 | hub.save_config(); // Ensure the options object is deleted from the file. 28 | } 29 | 30 | fenbox.value = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; 31 | 32 | // We have 3 main things that get drawn to: 33 | // 34 | // - boardsquares, lowest z-level table with enemy pieces and coloured squares. 35 | // - canvas, which gets arrows drawn on it. 36 | // - boardfriends, a table with friendly pieces. 37 | // 38 | // boardsquares has its natural position, while the other three get 39 | // fixed position that is set to be on top of it. 40 | 41 | boardfriends.width = canvas.width = boardsquares.width = config.board_size; 42 | boardfriends.height = canvas.height = boardsquares.height = config.board_size; 43 | 44 | rightgridder.style["height"] = `${canvas.height}px`; 45 | 46 | // Set up the squares in both tables. Note that, upon flips, the elements 47 | // themselves are moved to their new position, so everything works, e.g. 48 | // the x and y values are still correct for the flipped view. 49 | 50 | hub.change_background(config.override_board, false); 51 | 52 | for (let y = 0; y < 8; y++) { 53 | let tr1 = document.createElement("tr"); 54 | let tr2 = document.createElement("tr"); 55 | boardsquares.appendChild(tr1); 56 | boardfriends.appendChild(tr2); 57 | for (let x = 0; x < 8; x++) { 58 | let td1 = document.createElement("td"); 59 | let td2 = document.createElement("td"); 60 | td1.id = "underlay_" + S(x, y); 61 | td2.id = "overlay_" + S(x, y); 62 | td1.width = td2.width = config.square_size; 63 | td1.height = td2.height = config.square_size; 64 | tr1.appendChild(td1); 65 | tr2.appendChild(td2); 66 | td2.addEventListener("dragstart", (event) => { 67 | hub.set_active_square(Point(x, y)); 68 | event.dataTransfer.setData("text", "overlay_" + S(x, y)); 69 | }); 70 | } 71 | } 72 | 73 | statusbox.style["font-size"] = config.info_font_size.toString() + "px"; 74 | infobox.style["font-size"] = config.info_font_size.toString() + "px"; 75 | fullbox.style["font-size"] = config.info_font_size.toString() + "px"; 76 | movelist.style["font-size"] = config.pgn_font_size.toString() + "px"; 77 | fenbox.style["font-size"] = config.fen_font_size.toString() + "px"; 78 | 79 | if (config.graph_height <= 0) { 80 | graph.style.display = "none"; 81 | } else { 82 | graph.style.height = config.graph_height.toString() + "px"; 83 | graph.style.display = ""; 84 | } 85 | 86 | // The promotion table pops up when needed... 87 | 88 | promotiontable.style.left = (boardsquares.offsetLeft + config.square_size * 2).toString() + "px"; 89 | promotiontable.style.top = (boardsquares.offsetTop + config.square_size * 3.5).toString() + "px"; 90 | promotiontable.style["background-color"] = config.active_square; 91 | 92 | // -------------------------------------------------------------------------------------------- 93 | // In bad cases of super-large trees, the UI can become unresponsive. To mitigate this, we 94 | // put user input in a queue, and drop certain user actions if needed... 95 | 96 | let input_queue = []; 97 | 98 | ipcRenderer.on("set", (event, msg) => { // Should only be for things that don't need any action except redraw. 99 | for (let [key, value] of Object.entries(msg)) { 100 | config[key] = value; 101 | } 102 | hub.info_handler.must_draw_infobox(); 103 | hub.draw(); 104 | }); 105 | 106 | let droppables = [ // If the UI is already lagging, dropping one of these won't make it feel any worse. 107 | "goto_root", "goto_end", "prev", "next", "previous_sibling", "next_sibling", "return_to_main_line", "promote_to_main_line", 108 | "promote", "delete_node", "delete_children", "delete_siblings", "delete_other_lines", "return_to_lock", "play_info_index", 109 | "clear_searchmoves", "invert_searchmoves", 110 | ]; 111 | 112 | ipcRenderer.on("call", (event, msg) => { // Adds stuff to the queue, or drops some stuff. 113 | 114 | let fn; 115 | 116 | if (typeof msg === "string") { // msg is function name 117 | if (input_queue.length > 0 && droppables.includes(msg)) { 118 | return; 119 | } 120 | fn = hub[msg].bind(hub); 121 | } else if (typeof msg === "object" && typeof msg.fn === "string" && Array.isArray(msg.args)) { // msg is object with fn and args 122 | if (input_queue.length > 0 && droppables.includes(msg.fn)) { 123 | return; 124 | } 125 | fn = hub[msg.fn].bind(hub, ...msg.args); 126 | } else { 127 | console.log("Bad call, msg was..."); 128 | console.log(msg); 129 | } 130 | 131 | if (fn) { 132 | input_queue.push(fn); 133 | } 134 | }); 135 | 136 | // The queue needs to be examined very regularly and acted upon. 137 | 138 | function input_loop() { 139 | if (input_queue.length > 0) { 140 | for (let fn of input_queue) { 141 | fn(); 142 | } 143 | input_queue = []; 144 | } 145 | setTimeout(input_loop, 10); 146 | } 147 | 148 | input_loop(); 149 | 150 | // -------------------------------------------------------------------------------------------- 151 | // We had some problems with the various clickers: we used to destroy and create 152 | // clickable objects a lot. This seemed to lead to moments where clicks wouldn't 153 | // register. 154 | // 155 | // A better approach is to use event handlers on the outer elements, and examine 156 | // the event.path to see what was actually clicked on. 157 | 158 | fullbox.addEventListener("mousedown", (event) => { 159 | hub.fullbox_click(event); 160 | }); 161 | 162 | boardfriends.addEventListener("mousedown", (event) => { 163 | hub.boardfriends_click(event); 164 | }); 165 | 166 | infobox.addEventListener("mousedown", (event) => { 167 | hub.infobox_click(event); 168 | }); 169 | 170 | movelist.addEventListener("mousedown", (event) => { 171 | hub.movelist_click(event); 172 | }); 173 | 174 | statusbox.addEventListener("mousedown", (event) => { 175 | hub.statusbox_click(event); 176 | }); 177 | 178 | promotiontable.addEventListener("mousedown", (event) => { 179 | hub.promotiontable_click(event); 180 | }); 181 | 182 | // Graph clicks and dragging, borrowed from Ogatak... 183 | 184 | graph.addEventListener("mousedown", (event) => { 185 | hub.winrate_click(event); 186 | hub.grapher.dragging = true; 187 | }); 188 | 189 | for (let s of ["mousemove", "mouseleave"]) { 190 | 191 | graph.addEventListener(s, (event) => { 192 | if (!hub.grapher.dragging) { 193 | return; 194 | } 195 | if (!event.buttons) { 196 | hub.grapher.dragging = false; 197 | return; 198 | } 199 | hub.winrate_click(event); 200 | }); 201 | } 202 | 203 | window.addEventListener("mouseup", (event) => { 204 | hub.grapher.dragging = false; 205 | }); 206 | 207 | // 208 | 209 | window.addEventListener("wheel", (event) => { 210 | 211 | // Only if the PGN chooser is closed, and the mouse is over the board or graph. 212 | // (Not over the moveslist or infobox, because those can have scroll bars, which 213 | // the mouse wheel should interact with.) 214 | 215 | if (fullbox.style.display !== "none") { 216 | return; 217 | } 218 | 219 | // Not if the GUI has pending actions... 220 | 221 | if (input_queue.length > 0) { 222 | return; 223 | } 224 | 225 | let allow = false; 226 | 227 | let path = event.path || (event.composedPath && event.composedPath()); 228 | 229 | if (path) { 230 | for (let item of path) { 231 | if (item.id === "boardfriends" || item.id === "graph") { 232 | allow = true; 233 | break; 234 | } 235 | } 236 | } 237 | 238 | if (allow) { 239 | if (event.deltaY && event.deltaY < 0) input_queue.push(hub.prev.bind(hub)); 240 | if (event.deltaY && event.deltaY > 0) input_queue.push(hub.next.bind(hub)); 241 | } 242 | }); 243 | 244 | // Setup return key on FEN box... 245 | 246 | fenbox.addEventListener("keydown", (event) => { 247 | if (event.key === "Enter") { 248 | hub.load_from_fenbox(fenbox.value); 249 | } 250 | }); 251 | 252 | // Set space-bar to toggle go/halt, unless we're in the FEN box... 253 | 254 | window.addEventListener("keydown", (event) => { 255 | if (event.key === " ") { 256 | let ae = document.activeElement; 257 | if (ae.tagName !== "INPUT" && ae.tagName !== "TEXTAREA" && !ae.isContentEditable) { 258 | event.preventDefault(); // Prevent scrolling e.g. when the moves area is big enough to have a scroll bar. 259 | if (!event.repeat) { 260 | hub.toggle_go(); 261 | } 262 | } 263 | } 264 | }); 265 | 266 | // Setup drag-and-drop... 267 | 268 | window.addEventListener("dragenter", (event) => { // Necessary to prevent brief flashes of "not allowed" icon. 269 | event.preventDefault(); 270 | }); 271 | 272 | window.addEventListener("dragover", (event) => { // Necessary to prevent always having the "not allowed" icon. 273 | event.preventDefault(); 274 | }); 275 | 276 | window.addEventListener("drop", (event) => { 277 | event.preventDefault(); 278 | hub.handle_drop(event); 279 | }); 280 | 281 | window.addEventListener("resize", (event) => { 282 | hub.window_resize_time = performance.now(); 283 | }); 284 | 285 | window.addEventListener("error", (event) => { 286 | alert(messages.uncaught_exception); 287 | }, {once: true}); 288 | 289 | // Forced garbage collection. For reasons I can't begin to fathom, Node isn't 290 | // garbage collecting everything, and the heaps seems to grow and grow. It's 291 | // not what you would call a memory leak, since manually triggering the GC 292 | // does clear everything... note --max-old-space-size is another option. 293 | 294 | function force_gc() { 295 | if (!global || !global.gc) { 296 | console.log("Triggered GC not enabled."); 297 | return; 298 | } 299 | global.gc(); 300 | setTimeout(force_gc, 300000); // Once every 5 minutes or so? 301 | } 302 | 303 | setTimeout(force_gc, 300000); 304 | 305 | // Go... 306 | 307 | function enter_loop() { 308 | if (images.fully_loaded()) { 309 | hub.spin(); 310 | ipcRenderer.send("renderer_ready", null); 311 | } else { 312 | setTimeout(enter_loop, 25); 313 | } 314 | } 315 | 316 | enter_loop(); 317 | --------------------------------------------------------------------------------