├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── LICENSE.txt ├── Makefile ├── NOTE.txt ├── OBSOLETE.md ├── README.md ├── build_with ├── Makefile ├── config.json ├── pandoc_header ├── release.md └── splash.bmp ├── external ├── README.txt └── leela0110.rb ├── faces.png ├── lizgoban_windows.ps1 ├── lizgoban_windows.vbs ├── match.png ├── package.json ├── screen.gif ├── sound ├── README.md ├── capture18.mp3 ├── capture20.mp3 ├── capture58.mp3 ├── jara62.mp3 ├── put02.mp3 ├── put03.mp3 ├── put04.mp3 └── put05.mp3 ├── src ├── ai.js ├── amb_gain.js ├── area.js ├── branch.js ├── contributors.html ├── coord.js ├── copy_stones.js ├── draw.js ├── draw_common.js ├── draw_endstate_dist.js ├── draw_goban.js ├── draw_visits_trail.js ├── draw_winrate_bar.js ├── draw_winrate_graph.js ├── engine.js ├── exercise.js ├── fast_redo.js ├── game.js ├── globalize.js ├── help.css ├── help.html ├── help.js ├── help_ja.html ├── image_exporter.js ├── index.html ├── katago_rules.js ├── ladder.js ├── main.js ├── mcts │ ├── mcts.js │ ├── mcts_diagram.html │ └── mcts_main.js ├── no_thumbnail.png ├── option.js ├── package.json ├── persona_param.js ├── powered_goban.js ├── preference_window.css ├── preference_window.html ├── preference_window.js ├── random_flip.js ├── rankcheck_move.js ├── renderer.js ├── resign.js ├── rule.js ├── sgf_from_image │ ├── README.md │ ├── demo_auto.png │ ├── demo_hand.png │ ├── perspective.js │ ├── sgf_from_image.html │ └── sgf_from_image.js ├── tsumego_frame.js ├── util.js ├── weak_move.js └── window.js └── tree.png /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 13 * * 0' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['javascript'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /external/ 3 | /build_with/bin/ 4 | /build_with/extra/ 5 | /build_with/img/ 6 | 7 | node_modules/ 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RELEASE = 250527a 2 | 3 | VERSION = $(shell grep '"version"' package.json | cut -d '"' -f 4) 4 | EXE = dist/LizGoban\ $(VERSION).exe 5 | PACKAGE = tmpLizGoban-$(VERSION)_win_$(RELEASE) 6 | ZIP = tmp$(PACKAGE).zip 7 | 8 | local: 9 | (cd build_with; make) 10 | 11 | extra: 12 | (cd build_with; make all) 13 | 14 | $(EXE): 15 | npm i 16 | npm run build_win 17 | 18 | # force rebuilding 19 | win: extra 20 | npm i 21 | npm run build_win 22 | 23 | lin: extra 24 | npm i 25 | npm run build_lin 26 | 27 | ###################################### 28 | # zip 29 | 30 | $(PACKAGE): extra $(EXE) 31 | mkdir $(PACKAGE) 32 | cp $(EXE) build_with/config.json $(PACKAGE) 33 | cp -r build_with/bin/win/katago $(PACKAGE) 34 | cp build_with/bin/common/katanetwork.gz $(PACKAGE)/katago/default_model.bin.gz 35 | cp build_with/bin/common/kata_humanmodel.gz $(PACKAGE)/katago/human_model.bin.gz 36 | cp -r build_with/extra/* $(PACKAGE) 37 | 38 | $(ZIP): $(PACKAGE) 39 | cd $(PACKAGE) && zip -r $(PACKAGE).zip . && mv $(PACKAGE).zip .. 40 | 41 | zip: $(ZIP) 42 | -------------------------------------------------------------------------------- /NOTE.txt: -------------------------------------------------------------------------------- 1 | Note on release: 2 | 3 | - (Update "KATA_URL*" and "KATA_MODEL_URL" in build_with/Makefile. Remove build_with/bin.) 4 | - Update README.md ("Major changes" etc.) and build_with/release.md. 5 | - Update "version" in package.json and "RELEASE" in Makefile. 6 | - Do "make win" (to force rebuilding) and "make zip". 7 | - Rename, test, and upload tmpLizGoban-*.zip. 8 | 9 | Note on design: 10 | 11 | We try to avoid confirmation dialogs for any actions and enable "undo" 12 | of them instead. UI is kept modeless as far as possible. See "humane 13 | interface" for these points 14 | (https://en.wikipedia.org/wiki/The_Humane_Interface). 15 | 16 | Preferences are also kept as small as possible because they make 17 | user-support difficult. They also cause bugs that appear only in 18 | specific preferences and such bugs are often overlooked by the 19 | developers. 20 | 21 | Note on implementation: 22 | 23 | Leelaz is wrapped as if it is a stateless analyzer for convenience. 24 | The wrapped leelaz receives the history of moves from the beginning to 25 | the current board state for every analysis. Only the difference from 26 | the previous call is sent to leelaz internally for efficiency. 27 | 28 | Handicap stones are treated as usual moves internally and the move 29 | number is shifted only on the display. We dare to do this from the 30 | experience of repeated bugs on handicap games in Lizzie. 31 | 32 | src/package.json exists only for backward compatibility to enable "npx 33 | electron src". 34 | 35 | Note on confusing names (for historical reason): 36 | 37 | is_black - used in game history like {move: "D4", is_black: true, ...} 38 | black - used in stones (2D array) like {stone: true, black: true, ...} 39 | 40 | endstate - 2D array (positive = black) 41 | ownership - 1D array (positive = black) 42 | 43 | move_count - handicap stones are also counted. 44 | (First move = 1 in a normal game, 5 in a 4-handicap game.) 45 | 46 | Note on strategies: 47 | 48 | To add a new strategy for "match vs. AI", modify the following parts. 49 | 50 | - ` 27 | 28 | 29 | 30 |
31 | 32 | 33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | 47 |
48 | 49 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/preference_window.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { 4 | humansl_rank_profiles, 5 | humansl_preaz_profiles, 6 | humansl_proyear_profiles, 7 | } = require('./util.js') 8 | 9 | const electron = require('electron') 10 | const {send, sendSync} = electron.ipcRenderer 11 | const preferences = sendSync('get_preferences') 12 | const humansl_comparison = sendSync('get_humansl_comparison') 13 | 14 | function Q(x) {return document.querySelector(x)} 15 | function setq(x, val) {Q(x).textContent = val} 16 | function create(x) {return document.createElement(x)} 17 | function id_for(key) {return `checkbox_id_for_${key}`} 18 | 19 | const to_i = x => (x | 0) // to_i(true) is 1! 20 | const to_f = x => (x - 0) // to_f(true) is 1! 21 | 22 | const shortcut_action = {} 23 | 24 | window.onload = () => { 25 | const pref = Q('#preferences') 26 | preferences.forEach(([key, val, label_text, shortcut_key]) => { 27 | const id = id_for(key) 28 | // label 29 | const label = create('label') 30 | label.textContent = ` ${label_text}` 31 | label.setAttribute('for', id) 32 | // checkbox 33 | const checkbox = create('input') 34 | const on_change = () => send('set_preference', key, checkbox.checked) 35 | const toggle = () => {checkbox.checked = !checkbox.checked; on_change()} 36 | checkbox.type = 'checkbox' 37 | checkbox.id = id 38 | checkbox.checked = val 39 | checkbox.addEventListener('change', on_change) 40 | // shortcut 41 | const shortcut = create('code') 42 | shortcut.textContent = `[${shortcut_key}] ` 43 | shortcut.classList.add('shortcut') 44 | shortcut_action[shortcut_key] = toggle 45 | // div 46 | const div = create('div') 47 | div.classList.add('item') 48 | div.addEventListener('click', e => (e.target === div) && toggle()) 49 | div.append(shortcut, checkbox, label) 50 | pref.appendChild(div) 51 | }) 52 | // Q('#debug').textContent = JSON.stringify(preferences) 53 | initialize_humansl_comparison() 54 | } 55 | 56 | document.onkeydown = e => { 57 | if (e.key === "Escape" || e.ctrlKey && ["[", ","].includes(e.key)) {window.close(); return} 58 | if (e.ctrlKey || e.altKey || e.metaKey) {return} 59 | const action = shortcut_action[e.key] 60 | action && (e.preventDefault(), action()) 61 | } 62 | 63 | ///////////////////////////////////////////// 64 | // humanSL comparison 65 | 66 | const humansl_profile_lists = [ 67 | humansl_rank_profiles.toReversed(), 68 | humansl_preaz_profiles.toReversed(), 69 | humansl_proyear_profiles, 70 | ] 71 | function cum_len(aa) { 72 | const las = a => a.length - 1 73 | const f = (r, ps) => [...r, r[las(r)] + ps.length] 74 | return aa.slice(0, las(aa)).reduce(f, [0]) 75 | } 76 | const humansl_profile_options = [].concat(...humansl_profile_lists) 77 | const humansl_profile_markers = cum_len(humansl_profile_lists) 78 | 79 | function initialize_humansl_comparison() { 80 | const h = humansl_comparison, hpo = humansl_profile_options 81 | if (!h) {Q('#humansl_comparison_box').style.visibility = 'hidden'; return} 82 | const stronger_slider = Q('#humansl_stronger_profile') 83 | const weaker_slider = Q('#humansl_weaker_profile') 84 | stronger_slider.max = weaker_slider.max = humansl_profile_options.length - 1 85 | stronger_slider.value = hpo.indexOf(h.humansl_stronger_profile) 86 | weaker_slider.value = hpo.indexOf(h.humansl_weaker_profile) 87 | Q('#humansl_color_enhance').value = h.humansl_color_enhance 88 | const markers = Q('#humansl_profile_markers') 89 | humansl_profile_markers.forEach(m => { 90 | const option = create('option') 91 | option.value = m 92 | markers.appendChild(option) 93 | }) 94 | update_humansl_comparison(true) 95 | } 96 | 97 | function update_humansl_comparison(text_only_p) { 98 | const p = z => humansl_profile_options[to_i(Q(z).value)] 99 | const humansl_stronger_profile = p('#humansl_stronger_profile') 100 | const humansl_weaker_profile = p('#humansl_weaker_profile') 101 | const humansl_color_enhance = to_f(Q('#humansl_color_enhance').value) 102 | setq('#humansl_stronger_profile_label', humansl_stronger_profile) 103 | setq('#humansl_weaker_profile_label', humansl_weaker_profile) 104 | setq('#humansl_color_enhance_label', `color enhance ${humansl_color_enhance}`) 105 | if (text_only_p) {return} 106 | send('set_humansl_comparison', { 107 | humansl_stronger_profile, 108 | humansl_weaker_profile, 109 | humansl_color_enhance, 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /src/random_flip.js: -------------------------------------------------------------------------------- 1 | // frontend 2 | 3 | function random_flip_rotation(history) { 4 | return transform(history, coin_toss(), coin_toss(), coin_toss()) 5 | } 6 | 7 | function horizontal_flip(history) {return transform(history, false, true, false)} 8 | function vertical_flip(history) {return transform(history, true, false, false)} 9 | function clockwise_rotation(history) {return transform(history, true, false, true)} 10 | function counterclockwise_rotation(history) {return transform(history, false, true, true)} 11 | function half_turn(history) {return transform(history, true, true, false)} 12 | 13 | // backend 14 | 15 | function transform(history, ...spec) {return convert(ij_flipper(...spec), history)} 16 | 17 | function ij_flipper(flip_i, flip_j, swap_ij) { 18 | const fl = (k, bool) => bool ? (board_size() - 1 - k) : k 19 | const sw = (i, j, bool) => bool ? [j, i] : [i, j] 20 | return ([i, j]) => sw(fl(i, flip_i), fl(j, flip_j), swap_ij) 21 | } 22 | 23 | function convert(f, history) { 24 | const kept_keys = ['is_black', 'move_count', 'comment', 'note', 'tag'] 25 | const conv1 = h => { 26 | const {move} = h, ij = move2idx(move), pass = !idx2move(...ij) 27 | const converted_move = (pass ? move : idx2move(...f(ij))) 28 | return {...pick_keys(h, ...kept_keys), move: converted_move} 29 | } 30 | return history.map(conv1) 31 | } 32 | 33 | function coin_toss() {return Math.random() < 0.5} 34 | 35 | module.exports = { 36 | random_flip_rotation, horizontal_flip, vertical_flip, 37 | clockwise_rotation, counterclockwise_rotation, half_turn, 38 | ij_flipper, 39 | } 40 | -------------------------------------------------------------------------------- /src/rankcheck_move.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const {eval_with_persona, persona_param_str} = require('./weak_move.js') 4 | const {generate_persona_param} = require('./persona_param.js') 5 | 6 | /////////////////////////////////////////////// 7 | // main 8 | 9 | async function get_rankcheck_move(rank_profile, 10 | peek_kata_raw_human_nn, update_ponder_surely) { 11 | const policy_profile = rank_profile || 'rank_9d' 12 | const rank_delta = 2, profile_pair = profiles_around(policy_profile, rank_delta) 13 | const eval_move = async (move, peek) => 14 | eval_rankcheck_move(move, profile_pair, peek) 15 | const comment_title = `rankcheck ${profile_pair.join('/')}` 16 | const reverse_temperature = 0.9 17 | return get_move_gen({policy_profile, reverse_temperature, eval_move, comment_title, 18 | peek_kata_raw_human_nn, update_ponder_surely}) 19 | } 20 | 21 | async function get_move_gen(arg) { 22 | // "eval_move" is an async function that returns [badness, ...rest], 23 | // where "badness" is the target value which should be minimized 24 | // and "rest" is only used in the move comment. 25 | const {policy_profile, reverse_temperature, eval_move, comment_title, 26 | peek_kata_raw_human_nn, update_ponder_surely} = arg 27 | // param 28 | const max_candidates = 8, policy_move_prob = 0.1, policy_move_max_candidates = 50 29 | // util 30 | const peek = (moves, profile) => 31 | new Promise((res, rej) => peek_kata_raw_human_nn(moves, profile || '', res)) 32 | const evaluate = async move => eval_move(move, peek) 33 | const ret = (move, comment) => (update_ponder_surely(), [move, comment]) 34 | const scaling = p => (p || 0) ** reverse_temperature 35 | // proc 36 | const p0 = get_extended_policy(await peek([], policy_profile)) 37 | const sorted_p0 = sort_policy(p0).slice(0, policy_move_max_candidates) 38 | const randomly_picked_policy = weighted_random_choice(sorted_p0, scaling) 39 | const randomly_picked_move = serial2move(p0.indexOf(randomly_picked_policy)) 40 | // To avoid becoming too repetitive, 41 | // occasionally play randomly_picked_policy move. 42 | if (Math.random() < policy_move_prob || randomly_picked_move === pass_command) { 43 | const order = sorted_p0.indexOf(randomly_picked_policy) 44 | const comment = `(${comment_title}) by ${policy_profile} policy: ` + 45 | `${round(randomly_picked_policy)} (order ${order})` 46 | return ret(randomly_picked_move, comment) 47 | } 48 | // To exclude minor moves naturally, 49 | // use randomly_picked_policy as the lower bound. 50 | const top_indices_raw = get_top_indices(p0, max_candidates) 51 | const top_indices = top_indices_raw.filter(k => p0[k] >= randomly_picked_policy) 52 | const top_policies = top_indices.map(k => p0[k]) 53 | const top_moves = top_indices.map(serial2move) 54 | const evals = await ordered_async_map(top_moves, evaluate) 55 | const selected = min_by(top_moves, (_, k) => - evals[k][0]) // find max 56 | const comment = `(${comment_title}) ` + 57 | `Select ${selected} from [${top_moves.join(',')}].\n` + 58 | `policy = [${round(top_policies).join(', ')}]\n` + 59 | `eval = ${JSON.stringify(round(evals))}` 60 | return ret(selected, comment) 61 | } 62 | 63 | async function eval_rankcheck_move(move, profile_pair, peek) { 64 | // param 65 | const winrate_samples = 5, evenness_coef = 0.1 66 | const winrate_profile = null // null = normal katago 67 | // util 68 | const peek_policies = async profiles => { 69 | const f = async prof => get_extended_policy(await peek([move], prof)) 70 | return ordered_async_map(profiles, f) 71 | } 72 | const peek_winrates = async ms => { 73 | const f = async m => 74 | [m, (await peek([move, m], winrate_profile)).whiteWin[0]] 75 | return aa2hash(await ordered_async_map(ms, f)) 76 | } 77 | const get_candidates = p => { 78 | const indices = get_top_indices(p, winrate_samples) 79 | const moves = indices.map(serial2move) 80 | const policies = indices.map(k => p[k]) 81 | return {indices, moves, policies} 82 | } 83 | const expected_white_winrate = (candidates, union_wwin) => { 84 | const {moves, policies} = candidates 85 | const wwin = moves.map(m => union_wwin[m]) 86 | return sum(moves.map((_, k) => wwin[k] * policies[k])) / sum(policies) 87 | } 88 | // proc 89 | const policies_pair = await peek_policies(profile_pair) 90 | const candidates_pair = policies_pair.map(get_candidates) 91 | const union_moves = uniq(candidates_pair.flatMap(c => c.moves)) 92 | const union_wwin = await peek_winrates(union_moves) 93 | const white_winrate_pair = 94 | candidates_pair.map(c => expected_white_winrate(c, union_wwin)) 95 | // eval from opponent (= human) side 96 | const from_white_p = is_bturn(), flip = ww => from_white_p ? ww : 1 - ww 97 | const [wr_s, wr_w] = white_winrate_pair.map(flip) 98 | const mean = (wr_s + wr_w) / 2, diff = wr_s - wr_w 99 | // maximize "diff" and keep "mean" near 0.5 100 | const badness = (diff - 1)**2 + evenness_coef * (mean - 1/2)**2 101 | return [- badness, wr_s, wr_w] 102 | } 103 | 104 | /////////////////////////////////////////////// 105 | // variations 106 | 107 | async function get_center_move(policy_profile, 108 | peek_kata_raw_human_nn, update_ponder_surely) { 109 | return get_move_by_height(+1, policy_profile, 'center', 110 | peek_kata_raw_human_nn, update_ponder_surely) 111 | } 112 | 113 | async function get_edge_move(policy_profile, 114 | peek_kata_raw_human_nn, update_ponder_surely) { 115 | return get_move_by_height(-1, policy_profile, 'edge', 116 | peek_kata_raw_human_nn, update_ponder_surely) 117 | } 118 | 119 | async function get_move_by_height(sign, policy_profile, comment_title, 120 | peek_kata_raw_human_nn, update_ponder_surely) { 121 | const reverse_temperature = 0.9 122 | const eval_move = (move, _peek) => [sign * move_height(move)] 123 | return get_move_gen({policy_profile, reverse_temperature, eval_move, comment_title, 124 | peek_kata_raw_human_nn, update_ponder_surely}) 125 | } 126 | 127 | function move_height(move) { 128 | const bsize = board_size() 129 | const hs = move2idx(move).map(k => Math.min(k + 1, bsize - k)) 130 | return Math.min(...hs) + 0.01 * sum(hs) 131 | } 132 | 133 | /////////////////////////////////////////////// 134 | // persona 135 | 136 | async function get_hum_persona_move(policy_profile, 137 | peek_kata_raw_human_nn, update_ponder_surely, 138 | dummy_profile, code) { 139 | const reverse_temperature = 0.9 140 | const param = generate_persona_param(code).get() 141 | const desc = persona_param_str(param) 142 | const eval_move = persona_evaluator(param) 143 | const comment_title = `persona: ${policy_profile}, ${code} = ${desc}` 144 | return get_move_gen({policy_profile, reverse_temperature, eval_move, comment_title, 145 | peek_kata_raw_human_nn, update_ponder_surely}) 146 | } 147 | 148 | function persona_evaluator(param) { 149 | return async (move, peek) => { 150 | const profile = null // null = normal katago 151 | const ownership = (await peek([move], profile)).whiteOwnership.map(o => - o) 152 | return eval_with_persona(ownership, R.stones, param, is_bturn()) 153 | } 154 | } 155 | 156 | /////////////////////////////////////////////// 157 | // util 158 | 159 | function profiles_around(rank_profile, delta) { 160 | return [-1, +1].map(sign => prof_add(rank_profile, sign * delta)) 161 | } 162 | 163 | function prof_add(rank_profile, delta) { 164 | const a = humansl_rank_profiles, k = a.indexOf(rank_profile) + delta 165 | return a[clip(k, 0, a.length - 1)] 166 | } 167 | 168 | function get_extended_policy(raw_nn_output) { 169 | return [...raw_nn_output.policy, ...raw_nn_output.policyPass] 170 | } 171 | 172 | function sort_policy(a) {return num_sort(a.filter(truep)).reverse()} 173 | 174 | function get_top_indices(a, k) { 175 | return sort_policy(a).slice(0, k).map(z => a.indexOf(z)) 176 | } 177 | 178 | function round(z) {return is_a(z, 'number') ? to_f(z.toFixed(3)) : z.map(round)} 179 | 180 | async function ordered_async_map(a, f) { 181 | const iter = async (acc, ...args) => { 182 | const prev = await acc; return [...prev, await f(...args)] 183 | } 184 | return a.reduce(iter, Promise.resolve([])) 185 | } 186 | 187 | /////////////////////////////////////////////// 188 | // exports 189 | 190 | module.exports = { 191 | get_rankcheck_move, 192 | get_center_move, 193 | get_edge_move, 194 | get_hum_persona_move, 195 | } 196 | -------------------------------------------------------------------------------- /src/resign.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Copied parameters from 4 | // https://github.com/lightvector/KataGo/blob/v1.15.3/cpp/configs/gtp_human5k_example.cfg 5 | const resign_threshold = 0.005 6 | const resign_consec_turns = 20 7 | const resign_min_score_difference = 40 8 | const resign_min_moves_per_board_area = 0.4 9 | 10 | const the_winrate_records = {true: [], false: []} 11 | 12 | function get_record(bturn) {return the_winrate_records[!!bturn]} 13 | 14 | function record_winrate(bturn, game, my_winrate) { 15 | const record = get_record(bturn) 16 | const head = record[0], cur = game.ref_current() 17 | head?.[0] === cur && record.shift() // safety for duplicated call 18 | record.unshift([cur, my_winrate]) // record cur only for identity check 19 | record.splice(resign_consec_turns) 20 | } 21 | 22 | function is_record_hopeless(bturn, game) { 23 | const record = get_record(bturn) 24 | const hopeless = ([node, my_winrate], k) => { 25 | return game.ref(game.move_count - k * 2) === node && 26 | my_winrate < 100 * resign_threshold 27 | } 28 | return record.length >= resign_consec_turns && 29 | record.every(hopeless) 30 | } 31 | 32 | function should_resign_p(game, R) { 33 | const {bturn, b_winrate, score_without_komi, komi} = R 34 | if (!truep(b_winrate)) {return false} 35 | const score = true_or(score_without_komi, NaN) - komi 36 | const my_winrate = bturn ? b_winrate : 100 - b_winrate 37 | const my_score = !truep(score) ? - Infinity : bturn ? score : - score 38 | record_winrate(bturn, game, my_winrate) 39 | return is_record_hopeless(bturn, game) && 40 | (my_score <= - resign_min_score_difference) && 41 | game.movenum() >= game.board_size**2 * resign_min_moves_per_board_area 42 | } 43 | 44 | /////////////////////////////////////////////// 45 | // exports 46 | 47 | module.exports = { 48 | should_resign_p, 49 | } 50 | -------------------------------------------------------------------------------- /src/rule.js: -------------------------------------------------------------------------------- 1 | // illegal moves are not checked (ko, suicide, occupied place, ...) 2 | 3 | /////////////////////////////////////// 4 | // main 5 | 6 | function get_stones_and_set_ko_state(history) { 7 | // set "ko_state" of each element in history as side effect 8 | const stones = aa_new(board_size(), board_size(), () => ({})) 9 | const hama = {true: 0, false: 0}, ko_pool = [] 10 | history.forEach((h, k) => put(h, stones, hama, ko_pool, k === history.length - 1)) 11 | return {stones, black_hama: hama[true], white_hama: hama[false]} 12 | } 13 | 14 | function put(h, stones, hama, ko_pool, lastp) { 15 | const {move, is_black} = h 16 | const [i, j] = move2idx(move), pass = (i < 0); if (pass) {return} 17 | aa_set(stones, i, j, {stone: true, black: is_black, ...(lastp ? {last: true} : {})}) 18 | const ko_state = capture_stones_and_check_ko([i, j], is_black, stones, hama, ko_pool) 19 | merge(h, {ko_state}) // side effect! 20 | } 21 | 22 | function capture_stones_and_check_ko(ij, is_black, stones, hama, ko_pool) { 23 | const surrounded = is_surrounded_by_opponent(ij, is_black, stones) 24 | const captured_opponents = capture(ij, is_black, stones, hama) 25 | return check_ko(ij, is_black, surrounded, captured_opponents, stones, ko_pool) 26 | } 27 | 28 | /////////////////////////////////////// 29 | // capture 30 | 31 | function capture(ij, is_black, stones, hama) { 32 | let captured_opponents = [] 33 | around_idx(ij).forEach(idx => { 34 | const r = remove_captured(idx, !is_black, stones); captured_opponents.push(...r) 35 | hama[!!is_black] += r.length 36 | }) 37 | hama[!is_black] += remove_captured(ij, is_black, stones).length 38 | return captured_opponents 39 | } 40 | 41 | function remove_captured(ij, is_black, stones) { 42 | const captured = captured_from(ij, is_black, stones) 43 | captured.forEach(idx => aa_set(stones, ...idx, {})) 44 | return captured 45 | } 46 | 47 | function captured_from(ij, is_black, stones) { 48 | return low_liberty_group_from(ij, is_black, stones, 0) 49 | } 50 | 51 | function low_liberty_group_from(ij, is_black, stones, max_liberties) { 52 | const state = { 53 | hope: [], checked_pool: [], checked_map: [[]], liberties: 0, is_black, stones 54 | } 55 | check_if_liberty(ij, state) 56 | while (!empty(state.hope)) { 57 | search_for_liberty(state) 58 | if (state.liberties > max_liberties) {return []} 59 | } 60 | return state.checked_pool 61 | } 62 | 63 | function search_for_liberty(state) { 64 | around_idx(state.hope.shift()).forEach(idx => check_if_liberty(idx, state)) 65 | } 66 | 67 | function check_if_liberty(ij, state) { 68 | const s = aa_ref(state.stones, ...ij); if (!s) {return} 69 | s.stone ? push_hope(ij, s, state) : increment_liberties(ij, state) 70 | } 71 | 72 | function push_hope(ij, s, state) { 73 | !xor(s.black, state.is_black) && check_map(ij, state) && 74 | (state.hope.push(ij), state.checked_pool.push(ij)) 75 | } 76 | 77 | function increment_liberties(ij, state) {check_map(ij, state) && (state.liberties++)} 78 | 79 | function check_map(ij, {checked_map}) { 80 | return !aa_ref(checked_map, ...ij) && aa_set(checked_map, ...ij, true) 81 | } 82 | 83 | /////////////////////////////////////// 84 | // ko fight 85 | 86 | // ko_pool = [ko_item, ko_item, ...] 87 | // ko_item = {move_idx: [5, 3], is_black: true, captured_idx: [5, 4]} 88 | 89 | function check_ko(ij, is_black, surrounded, captured_opponents, stones, ko_pool) { 90 | remove_obsolete_ko(stones, ko_pool) 91 | const captured = !empty(captured_opponents) 92 | const ko_captured = 93 | check_ko_captured(ij, is_black, surrounded, captured_opponents, ko_pool) 94 | const resolved_by_connection = check_resolved_by_connection(ij, ko_pool) 95 | // For two-stage ko, 96 | // check_resolved_by_capture() is necessary anyway even if ko_captured is true. 97 | const resolved_by_capture = 98 | check_resolved_by_capture(stones, ko_pool) && !ko_captured 99 | // ugly! The element "captured" is just a by-product here and it is used 100 | // for "capturing sound" effect, that is unrelated to "ko" actually. 101 | return {captured, ko_captured, resolved_by_connection, resolved_by_capture} 102 | } 103 | 104 | function remove_obsolete_ko(stones, ko_pool) { 105 | filter_ko_pool(ko_pool, ({move_idx, is_black}) => { 106 | const s = aa_ref(stones, ...move_idx) 107 | return s.stone && (!!is_black === !!s.black) 108 | }) 109 | } 110 | 111 | function check_ko_captured(move_idx, is_black, surrounded, captured_opponents, ko_pool) { 112 | const ko_captured = surrounded && (captured_opponents.length === 1) 113 | ko_captured && 114 | ko_pool.push({move_idx, is_black, captured_idx: captured_opponents[0]}) 115 | return ko_captured 116 | } 117 | 118 | function check_resolved_by_connection(ij, ko_pool) { 119 | return filter_ko_pool(ko_pool, ({captured_idx}) => !idx_equal(captured_idx, ij)) 120 | } 121 | 122 | function check_resolved_by_capture(stones, ko_pool) { 123 | return filter_ko_pool(ko_pool, ({move_idx}) => around_idx(move_idx).filter(ij => { 124 | const s = aa_ref(stones, ...ij) 125 | return s && !s.stone 126 | }).length <= 1) 127 | } 128 | 129 | function filter_ko_pool(ko_pool, pred) { 130 | const new_ko_pool = ko_pool.filter(pred) 131 | const filtered_p = new_ko_pool.length < ko_pool.length 132 | copy_array(new_ko_pool, ko_pool) 133 | return filtered_p 134 | } 135 | 136 | function is_surrounded_by_opponent(ij, is_black, stones) { 137 | const blocked = s => !s || (s.stone && xor(is_black, s.black)) 138 | return around_idx(ij).every(idx => blocked(aa_ref(stones, ...idx))) 139 | } 140 | 141 | function idx_equal([i1, j1], [i2, j2]) {return i1 === i2 && j1 === j2} 142 | 143 | function copy_array(from, to) {to.splice(0, Infinity, ...from)} 144 | 145 | /////////////////////////////////////// 146 | // liberty check 147 | 148 | function has_liberty(ij, stones, min_liberty) { 149 | return !is_low_liberty(ij, stones, min_liberty - 1) 150 | } 151 | 152 | function is_low_liberty(ij, stones, max_liberty) { 153 | const s = aa_ref(stones, ...ij); if (!s) {return false} 154 | const group = low_liberty_group_from(ij, s.black, stones, max_liberty) 155 | return s && !empty(group) 156 | } 157 | 158 | /////////////////////////////////////// 159 | // exports 160 | 161 | module.exports = { 162 | get_stones_and_set_ko_state, 163 | has_liberty, 164 | } 165 | -------------------------------------------------------------------------------- /src/sgf_from_image/README.md: -------------------------------------------------------------------------------- 1 | # SGF from Image 2 | 3 | This is a semiautomatic converter from diagram images of the game Go (Weiqi, Baduk) to SGF format. Try an [online demo](http://kaorahi.github.io/lizgoban/src/sgf_from_image/sgf_from_image.html). 4 | 5 | The contents of this directory will work independent of [LizGoban](https://github.com/kaorahi/lizgoban) if you just put them on a web server. Using them locally without a web server is troublesome because image accesses are refused by security mechanisms of web browsers. 6 | 7 | (Example of local testing) 8 | 9 | ~~~ 10 | cd lizgoban 11 | python -m SimpleHTTPServer 12 | firefox http://localhost:8000/src/sgf_from_image/sgf_from_image.html 13 | ~~~ 14 | -------------------------------------------------------------------------------- /src/sgf_from_image/demo_auto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/src/sgf_from_image/demo_auto.png -------------------------------------------------------------------------------- /src/sgf_from_image/demo_hand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/src/sgf_from_image/demo_hand.png -------------------------------------------------------------------------------- /src/sgf_from_image/perspective.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // MEMO 4 | // 5 | // Problem: 6 | // From given [xi, yi] and [ui, vi] (i = 1,2,3,4) 7 | // find a matrix R and numbers ci such that 8 | // [xi, yi, 1] R = ci [ui, vi, 1]. 9 | // 10 | // Solution: 11 | // Set c4 = 1 without loss of generality. 12 | // Let 3-dim row vectors si = [xi, yi, 1], ti = [ui, vi, 1], 13 | // and build 3x3 matrices S = [s1; s2; s3], T = [t1; t2; t3] 14 | // from the above row vectors. (The first row of S is s1.) 15 | // Further, let C = diag(c1, c2, c3) be 3x3 diagonal matrix. 16 | // Then the given condition is S R = C T and s4 R = t4. 17 | // So we obtain R = S^{-1} C T and s4 S^{-1} C T = t4. 18 | // Hence s4 S^{-1} C = t4 T^{-1}. 19 | // Namely, we get c1, c2, c3 as the ratio of elements between 20 | // the row vectors p = s4 S^{-1} and q = t4 T^{-1}. 21 | // 22 | // Calculation: 23 | // $ echo '""; a: matrix([x1,y1,1],[x2,y2,1],[x3,y3,1]); d: determinant(a); d * (a^^-1), factor;' | maxima -q 24 | // [ x1 y1 1 ] 25 | // [ ] 26 | // (%o2) [ x2 y2 1 ] 27 | // [ ] 28 | // [ x3 y3 1 ] 29 | // (%o3) x2 y3 + x1 (y2 - y3) - x3 y2 - (x2 - x3) y1 30 | // [ - (y3 - y2) y3 - y1 - (y2 - y1) ] 31 | // [ ] 32 | // (%o4) [ x3 - x2 - (x3 - x1) x2 - x1 ] 33 | // [ ] 34 | // [ x2 y3 - x3 y2 - (x1 y3 - x3 y1) x1 y2 - x2 y1 ] 35 | 36 | function perspective_transformer(...args) { 37 | 38 | ///////////////////////////////////// 39 | 40 | // in: xyi = [xi,yi], uvi = [ui,vi] 41 | // out: f() such that f([xi,yi]) = [ui,vi] 42 | function transformer(xy1, xy2, xy3, xy4, uv1, uv2, uv3, uv4) { 43 | const ks = [0, 1, 2] 44 | const extend = a => [...a, 1] 45 | const [s1, s2, s3, s4, t1, t2, t3, t4] = 46 | [xy1, xy2, xy3, xy4, uv1, uv2, uv3, uv4].map(extend) 47 | const s_mat = [...s1, ...s2, ...s3], t_mat = [...t1, ...t2, ...t3] 48 | const s_inv = inv(xy1, xy2, xy3), t_inv = inv(uv1, uv2, uv3) 49 | const p = prod(s4, s_inv), q = prod(t4, t_inv) 50 | const c = ks.map(k => q[k] / p[k]) 51 | const r_mat = mat_prod(s_inv, mat_prod(diag(c), t_mat)) 52 | const f = xy => { 53 | const [u_, v_, w_] = prod(extend(xy), r_mat) 54 | return [u_ / w_, v_ / w_] 55 | } 56 | return f 57 | } 58 | 59 | // return [a, ..., i] such that the inverse matrix of 60 | // x1 y1 1 61 | // x2 y2 1 62 | // x3 y3 1 63 | // is 64 | // a b c 65 | // d e f 66 | // g h i 67 | function inv([x1,y1], [x2,y2], [x3,y3]) { 68 | const y12 = y1 - y2, y23 = y2 - y3, y31 = y3 - y1 69 | const det = x1 * y23 + x2 * y31 + x3 * y12 70 | const det_inv = [ 71 | y23, y31, y12, 72 | x3 - x2, x1 - x3, x2 - x1, 73 | x2 * y3 - x3 * y2, x3 * y1 - x1 * y3, x1 * y2 - x2 * y1, 74 | ] 75 | return det_inv.map(z => z / det) 76 | } 77 | 78 | // vector-matrix product [x,y,z] A for A = 79 | // a11 a12 a13 80 | // a21 a22 a23 81 | // a31 a32 a33 82 | function prod([x,y,z], [a11,a12,a13, a21,a22,a23, a31,a32,a33]) { 83 | return [x*a11+y*a21+z*a31, x*a12+y*a22+z*a32, x*a13+y*a23+z*a33] 84 | } 85 | 86 | // matrix-matrix product A B for A = 87 | // a11 a12 a13 88 | // a21 a22 a23 89 | // a31 a32 a33 90 | // and similar B 91 | function mat_prod([a11,a12,a13, a21,a22,a23, a31,a32,a33], b) { 92 | return [[a11,a12,a13], [a21,a22,a23], [a31,a32,a33]].flatMap(row => prod(row, b)) 93 | } 94 | 95 | function diag([c1, c2, c3]) {return [c1,0,0, 0,c2,0, 0,0,c3]} 96 | 97 | ///////////////////////////////////// 98 | 99 | return transformer(...args) 100 | 101 | } 102 | 103 | // EXAMPLE 104 | // console.log(perspective_transformer([3,1],[4,1],[5,9],[2,6], [30,10],[40,10],[50,90],[20,60])([5,3])) 105 | // ==> [50, 30] 106 | // console.log(perspective_transformer([3,1],[4,1],[5,9],[2,6], [103,-101],[104,-101],[105,-109],[102,-106])([5,3])) 107 | // ==> [105, -103] 108 | -------------------------------------------------------------------------------- /src/sgf_from_image/sgf_from_image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SGF from Image 7 | 8 | 9 | 10 | 16 | 32 | 33 | 34 | 35 | 75 | 76 | 77 | 78 | 79 |
Loading...
80 | 81 | 214 | 215 | 216 | -------------------------------------------------------------------------------- /src/tsumego_frame.js: -------------------------------------------------------------------------------- 1 | const {ij_flipper} = require('./random_flip.js') 2 | 3 | ////////////////////////////////////// 4 | // main 5 | 6 | function tsumego_frame(stones, komi, black_to_play_p, ko_p) { 7 | // util 8 | const pick = key => (s, i, j) => s[key] && [i, j, s.black] 9 | const pick_all = (given_stones, key) => 10 | aa_map(given_stones, pick(key)).flat().filter(truep) 11 | const range = ks => [Math.min(...ks), Math.max(...ks)] 12 | // main 13 | const filled_stones = tsumego_frame_stones(stones, komi, black_to_play_p, ko_p) 14 | const fill = pick_all(filled_stones, 'tsumego_frame') 15 | const region_pos = pick_all(filled_stones, 'tsumego_frame_region_mark') 16 | const analysis_region = !empty(region_pos) && 17 | aa_transpose(region_pos).slice(0, 2).map(range) 18 | const validate = region => (region || []).every(([a, b]) => a < b) && region 19 | return [fill, validate(analysis_region)] 20 | } 21 | 22 | function tsumego_frame_stones(orig_stones, komi, black_to_play_p, ko_p) { 23 | const size = board_size() 24 | const stones = aa_dup_hash(orig_stones) 25 | const ijs = aa_map(stones, (h, i, j) => h.stone && {i, j, black: h.black}).flat() 26 | .filter(truep) 27 | if (empty(ijs)) {return []} 28 | // detect corner/edge/center problems 29 | // (avoid putting border stones on the first lines) 30 | const near_to_edge = 2 31 | const snapper = to => k => Math.abs(k - to) <= near_to_edge ? to : k 32 | const snap0 = snapper(0), snapS = snapper(size - 1) 33 | // find range of problem 34 | const top = min_by(ijs, z => z.i), bottom = min_by(ijs, z => - z.i) 35 | const left = min_by(ijs, z => z.j), right = min_by(ijs, z => - z.j) 36 | const imin = snap0(top.i), imax = snapS(bottom.i) 37 | const jmin = snap0(left.j), jmax = snapS(right.j) 38 | // flip/rotate for standard position 39 | const need_flip_p = (kmin, kmax) => (kmin < size - kmax - 1) 40 | const flip_spec = (imin < jmin) ? [false, false, true] : 41 | // don't mix flip and swap (FF = SS = identity, but SFSF != identity) 42 | [need_flip_p(imin, imax), need_flip_p(jmin, jmax), false] 43 | if (flip_spec.find(truep)) { 44 | const flip = ss => flip_stones(ss, flip_spec) 45 | const fill = ss => tsumego_frame_stones(ss, komi, black_to_play_p, ko_p) 46 | return flip(fill(flip(stones))) 47 | } 48 | // put outside stones 49 | const margin = 2 50 | const i0 = imin - margin, i1 = imax + margin, j0 = jmin - margin, j1 = jmax + margin 51 | const frame_range = [i0, i1, j0, j1] 52 | const black_to_attack_p = guess_black_to_attack([top, bottom, left, right], size) 53 | put_border(stones, size, frame_range, black_to_attack_p) 54 | put_outside(stones, size, frame_range, black_to_attack_p, black_to_play_p, komi) 55 | put_ko_threat(stones, size, frame_range, black_to_attack_p, black_to_play_p, ko_p) 56 | return stones 57 | } 58 | 59 | function guess_black_to_attack(extrema, size) { 60 | const height = k => size - Math.abs(k - (size - 1) / 2) 61 | const height2 = z => height(z.i) + height(z.j) 62 | return sum(extrema.map(z => (z.black ? 1 : -1) * height2(z))) > 0 63 | } 64 | 65 | ////////////////////////////////////// 66 | // sub 67 | 68 | function put_border(stones, size, frame_range, is_black) { 69 | const [i0, i1, j0, j1] = frame_range 70 | const ij_for = (k, at, reverse_p) => reverse_p ? [at, k] : [k, at] 71 | const put = (k, at, reverse_p) => 72 | put_stone(stones, size, ...ij_for(k, at, reverse_p), is_black, false, true) 73 | const put_line = (from, to, at, reverse_p) => 74 | seq_from_to(from, to).forEach(k => put(k, at, reverse_p)) 75 | const put_twin = (from, to, at0, at1, reverse_p) => 76 | [at0, at1].map(at => put_line(from, to, at, reverse_p)) 77 | put_twin(i0, i1, j0, j1, false); put_twin(j0, j1, i0, i1, true) 78 | } 79 | 80 | function put_outside(stones, size, frame_range, 81 | black_to_attack_p, black_to_play_p, komi) { 82 | let count = 0 83 | const offence_to_win = 5, offense_komi = (black_to_attack_p ? 1 : -1) * komi 84 | const defense_area = (size * size - offense_komi - offence_to_win) / 2 85 | const black_p = () => xor(black_to_attack_p, (count <= defense_area)) 86 | const empty_p = (i, j) => ((i + j) % 2 === 0 && Math.abs(count - defense_area) > size) 87 | const put = (i, j) => !inside_p(i, j, frame_range) && 88 | (++count, put_stone(stones, size, i, j, black_p(), empty_p(i, j))) 89 | const [is, js] = seq(2).map(_ => seq_from_to(0, size - 1)) 90 | is.forEach(i => js.forEach(j => put(i, j))) 91 | } 92 | 93 | // standard position: 94 | // ? = problem, X = offense, O = defense 95 | // OOOOOOOOOOOOO 96 | // OOOOOOOOOOOOO 97 | // OOOOOOOOOOOOO 98 | // XXXXXXXXXXXXX 99 | // XXXXXXXXXXXXX 100 | // XXXX......... 101 | // XXXX.XXXXXXXX 102 | // XXXX.X??????? 103 | // XXXX.X??????? 104 | 105 | // [pattern, top_p, left_p] 106 | const offense_ko_threat = [` 107 | ....OOOX. 108 | .....XXXX 109 | `, true, false] 110 | const defense_ko_threat = [` 111 | .. 112 | .. 113 | X. 114 | XO 115 | OO 116 | .O 117 | `, false, true] 118 | 119 | // // more complicated ko threats 120 | // const offense_ko_threat = [` 121 | // ..OOX. 122 | // ...XXX 123 | // ...... 124 | // ...... 125 | // `, true, false] 126 | // const defense_ko_threat = [` 127 | // .... 128 | // .... 129 | // X... 130 | // XO.. 131 | // OO.. 132 | // .O.. 133 | // `, false, true] 134 | 135 | function put_ko_threat(stones, size, frame_range, 136 | black_to_attack_p, black_to_play_p, ko_p) { 137 | if (ko_p === 'no_ko_threat') {return} 138 | const for_offense_p = xor(ko_p, xor(black_to_attack_p, black_to_play_p)) 139 | const [pattern, top_p, left_p] = for_offense_p ? 140 | offense_ko_threat : defense_ko_threat 141 | const aa = pattern.split(/\n/).filter(identity).map(s => s.split('')) 142 | const width = aa[0].length, height = aa.length 143 | const put = (ch, i, j) => { 144 | const conv = ([k, normal_p, len]) => (normal_p ? 0 : size - len) + k 145 | const ij = [[i, top_p, height], [j, left_p, width]].map(conv) 146 | if (inside_p(...ij, frame_range)) {return} 147 | const black = xor(black_to_attack_p, ch === 'O'), empty = (ch === '.') 148 | put_stone(stones, size, ...ij, black, empty) 149 | } 150 | aa_each(aa, put) 151 | } 152 | 153 | ////////////////////////////////////// 154 | // util 155 | 156 | function flip_stones(stones, flip_spec) { 157 | const new_stones = [[]], new_ij = ij_flipper(...flip_spec) 158 | aa_each(stones, (z, ...ij) => aa_set(new_stones, ...new_ij(ij), z)) 159 | return new_stones 160 | } 161 | 162 | function put_stone(stones, size, i, j, black, empty, tsumego_frame_region_mark) { 163 | if (i < 0 || size <= i || j < 0 || size <= j) {return} 164 | aa_set(stones, i, j, 165 | empty ? {} : {tsumego_frame: true, black, tsumego_frame_region_mark}) 166 | } 167 | 168 | function inside_p(i, j, [i0, i1, j0, j1]) { 169 | return clip(i, i0, i1) === i && clip(j, j0, j1) === j 170 | } 171 | 172 | ////////////////////////////////////// 173 | // exports 174 | 175 | module.exports = { 176 | tsumego_frame, 177 | } 178 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const CRYPTO = require('crypto') 2 | 3 | // utilities 4 | 5 | const E = {} 6 | 7 | E.sha256sum = x => CRYPTO.createHash('sha256').update(x).digest('hex') 8 | 9 | E.to_i = x => (x | 0) // to_i(true) is 1! 10 | E.to_f = x => (x - 0) // to_f(true) is 1! 11 | E.to_s = x => (x + '') 12 | E.xor = (a, b) => (!a === !!b) 13 | // truep() returns BOOLEAN so that availability() is safely serialized and 14 | // passed to renderer in main.js. [2020-09-05] 15 | E.truep = x => (!!x || x === 0 || x === '') 16 | E.true_or = (x, y) => E.truep(x) ? x : y 17 | E.finitep = x => E.truep(x) && x !== Infinity 18 | E.finite_or = (x, y) => E.finitep(x) ? x : y 19 | E.do_nothing = () => {} 20 | E.identity = x => x 21 | E.is_a = (obj, type) => (typeof obj === type) 22 | E.stringp = obj => E.is_a(obj, 'string') 23 | E.valid_numberp = obj => E.is_a(obj, 'number') && !isNaN(obj) 24 | E.functionp = obj => E.is_a(obj, 'function') 25 | E.clip = (x, lower, upper) => 26 | Math.max(lower, Math.min(x, E.truep(upper) ? upper : Infinity)) 27 | E.sum = a => a.reduce((r,x) => r + x, 0) 28 | E.average = a => E.sum(a) / a.length 29 | E.weighted_average = (a, w) => E.sum(a.map((z, k) => z * w[k])) / E.sum(w) 30 | // E.clone = x => JSON.parse(JSON.stringify(x)) 31 | E.merge = Object.assign 32 | E.empty = a => !a || (a.length === 0) 33 | E.last = a => a.at(-1) 34 | E.uniq = a => [...new Set(a)] 35 | E.sort_by = (a, f) => a.slice().sort((x, y) => f(x) - f(y)) 36 | E.sort_by_key = (a, key) => sort_by(a, h => h[key]) 37 | E.num_sort = a => sort_by(a, E.identity) 38 | E.argmin_by = (a, f) => {const b = a.map(f), m = Math.min(...b); return b.indexOf(m)} 39 | E.min_by = (a, f) => a[E.argmin_by(a, f)] 40 | E.replace_header = (a, header) => a.splice(0, header.length, ...header) 41 | E.each_key_value = (h, f) => Object.keys(h).forEach(k => f(k, h[k])) 42 | E.map_key_value = (h, f) => Object.keys(h).map(k => f(k, h[k])) 43 | E.each_value = (h, f) => each_key_value(h, (_, v) => f(v)) // for non-array 44 | E.array2hash = a => { 45 | // array2hash(['a', 3, 'b', 1, 'c', 4]) ==> {a: 3, b: 1, c: 4} 46 | const h = {}; a.forEach((x, i) => (i % 2 === 0) && (h[x] = a[i + 1])); return h 47 | } 48 | E.pick_keys = (h, ...keys) => { 49 | const picked = {}; keys.forEach(k => picked[k] = h[k]); return picked 50 | } 51 | E.ref_or_create = (h, key, default_val) => h[key] || (h[key] = default_val) 52 | E.safely = (proc, ...args) => E.safely_or(proc, args, e => null) 53 | E.verbose_safely = (proc, ...args) => E.safely_or(proc, args, console.log) 54 | E.safely_or = (proc, args, catcher) => { 55 | try {return proc(...args)} catch(e) {return catcher(e)} 56 | } 57 | 58 | E.mac_p = () => (process.platform === 'darwin') 59 | E.leelaz_komi = 7.5 60 | E.handicap_komi = -0.5 61 | E.default_gorule = 'chinese' 62 | E.blunder_threshold = -2 63 | E.big_blunder_threshold = -5 64 | E.blunder_low_policy = 0.1 65 | E.blunder_high_policy = 0.75 66 | E.black_to_play_p = (forced, bturn) => forced ? (forced === 'black') : bturn 67 | 68 | // seq(3) = [ 0, 1, 2 ], seq(3, 5) = [ 5, 6, 7 ], seq(-2) = [] 69 | // seq_from_to(3,5) = [3, 4, 5], seq_from_to(5,3) = [] 70 | E.seq = (n, from) => Array(E.clip(n, 0)).fill().map((_, i) => i + (from || 0)) 71 | E.seq_from_to = (from, to) => E.seq(to - from + 1, from) 72 | E.do_ntimes = (n, f) => E.seq(n).forEach(f) 73 | 74 | // change_points('aaabcc'.split('')) ==> [3, 4] 75 | // unchanged_ranges('aaabcc'.split('')) ==> [['a', 0, 2], ['b', 3, 3], ['c', 4, 5]] 76 | E.change_points = a => a.map((z, k) => k > 0 && a[k - 1] !== a[k] && k).filter(truep) 77 | E.unchanged_periods = 78 | a => empty(a) ? [] : [0, ...change_points([...a, {}])].map((k, l, cs) => { 79 | const next = cs[l + 1] 80 | return next && [a[k], k, next - 1] 81 | }).filter(truep) 82 | 83 | // "magic" in ai.py of KaTrain 84 | // seq(1000).map(_ => weighted_random_choice([1,2,3,4], identity)).filter(x => x === 3).length 85 | // ==> around 300 86 | E.weighted_random_choice = (ary, weight_of) => { 87 | const magic = (...args) => - Math.log(Math.random()) / (weight_of(...args) + 1e-18) 88 | return E.min_by(ary, magic) 89 | } 90 | E.random_choice = ary => weighted_random_choice(ary, () => 1) 91 | E.random_int = k => Math.floor(Math.random() * k) 92 | 93 | // array of array 94 | E.aa_new = (m, n, f) => E.seq(m).map(i => E.seq(n).map(j => f(i, j))) 95 | E.aa_ref = (aa, i, j) => truep(i) && (i >= 0) && aa[i] && aa[i][j] 96 | E.aa_set = (aa, i, j, val) => 97 | truep(i) && (i >= 0) && ((aa[i] = aa[i] || []), (aa[i][j] = val)) 98 | E.aa_each = (aa, f) => aa.forEach((row, i) => row.forEach((s, j) => f(s, i, j))) 99 | E.aa_map = (aa, f) => aa.map((row, i) => row.map((s, j) => f(s, i, j))) 100 | E.aa_transpose = aa => empty(aa) ? [] : aa[0].map((_, k) => aa.map(a => a[k])) 101 | E.aa_dup_hash = aa => E.aa_map(aa, h => ({...h})) 102 | E.aa2hash = aa => {const h = {}; aa.forEach(([k, v]) => h[k] = v); return h} 103 | E.around_idx_diff = [[1, 0], [0, 1], [-1, 0], [0, -1]] 104 | E.around_idx = ([i, j]) => { 105 | const neighbor = ([di, dj]) => [i + di, j + dj] 106 | return around_idx_diff.map(neighbor) 107 | } 108 | 109 | // [0,1,2,3,4,5,6,7,8,9,10,11,12].map(k => kilo_str(10**k)) ==> 110 | // ['1','10','100','1.0K','10K','100K','1.0M','10M','100M','1.0G','10G','100G','1000G'] 111 | E.kilo_str = x => kilo_str_sub(x, [[1e9, 'G'], [1e6, 'M'], [1e3, 'k']]) 112 | 113 | function kilo_str_sub(x, rules) { 114 | if (empty(rules)) {return to_s(x)} 115 | const [[base, unit], ...rest] = rules 116 | if (x < base) {return kilo_str_sub(x, rest)} 117 | // +0.1 for "1.0K" instead of "1K" 118 | const y = (x + 0.1) / base, z = Math.floor(y) 119 | return (y < 10 ? to_s(y).slice(0, 3) : to_s(z)) + unit 120 | } 121 | 122 | // str_sort_uniq('zabcacd') = 'abcdz' 123 | E.str_sort_uniq = str => E.uniq(str.split('')).sort().join('') 124 | 125 | let debug_log_p = false 126 | let debug_log_prev_category = null 127 | let debug_log_snipped_lines = 0, debug_log_last_snipped_line = null 128 | E.debug_log = (arg, limit_len, category) => is_a(arg, 'boolean') ? 129 | (debug_log_p = arg) : (debug_log_p && do_debug_log(arg, limit_len, category)) 130 | function do_debug_log(arg, limit_len, category) { 131 | const sec = `(${(new Date()).toJSON().replace(/(.*T)|(.Z)/g, '')}) ` 132 | // const sec = `(${(new Date()).toJSON().replace(/(.*:)|(.Z)/g, '')}) ` 133 | const line = sec + snip(E.to_s(arg), limit_len) 134 | const same_category_p = (category && (category === debug_log_prev_category)) 135 | debug_log_prev_category = category 136 | if (same_category_p) { 137 | debug_log_snipped_lines++ === 0 && console.log('...snipping lines...') 138 | debug_log_last_snipped_line = line 139 | return 140 | } 141 | --debug_log_snipped_lines > 0 && 142 | console.log(`...${debug_log_snipped_lines} lines are snipped.`) 143 | debug_log_snipped_lines = 0 144 | debug_log_last_snipped_line && console.log(debug_log_last_snipped_line) 145 | debug_log_last_snipped_line = null 146 | console.log(line) 147 | } 148 | E.snip = (str, limit_len) => { 149 | const half = Math.floor((limit_len || Infinity) / 2) 150 | return snip_text(str, half, half, over => `{...${over}...}`) 151 | } 152 | E.snip_text = (str, head, tail, dots) => { 153 | const over = str.length - (head + tail), raw = stringp(dots) 154 | return over <= 0 ? str : 155 | str.slice(0, head) + (raw ? dots : dots(over)) + (tail > 0 ? str.slice(- tail) : '') 156 | } 157 | 158 | E.orig_suggest_p = s => s.order >= 0 159 | 160 | E.endstate_from_ownership = 161 | ownership => E.aa_new(board_size(), board_size(), 162 | (i, j) => ownership[idx2serial(i, j)]) 163 | 164 | E.endstate_entropy = es => { 165 | const log2 = p => Math.log(p) / Math.log(2) 166 | const h = p => (p > 0) ? (- p * log2(p)) : 0 167 | const entropy = p => h(p) + h(1 - p) 168 | return entropy((es + 1) / 2) 169 | } 170 | 171 | E.cached = f => { 172 | let cache = {}; return key => cache[key] || (cache[key] = f(key)) 173 | } 174 | 175 | E.change_detector = init_val => { 176 | let prev 177 | const is_changed = val => {const changed = (val != prev); prev = val; return changed} 178 | const reset = () => (prev = init_val); reset() 179 | return {is_changed, reset} 180 | } 181 | 182 | // [d_f, d_g] = deferred_procs([f, 200], [g, 300]) 183 | // d_f(1,2,3) ==> f(1,2,3) is called after 200 ms 184 | // d_f(1,2,3) and then d_g(4,5) within 200 ms 185 | // ==> f is cancelled and g(4,5) is called after 300 ms 186 | E.deferred_procs = (...proc_delay_pairs) => { 187 | let timer 188 | return proc_delay_pairs.map(([proc, delay]) => ((...args) => { 189 | clearTimeout(timer); timer = setTimeout(() => proc(...args), delay) 190 | })) 191 | } 192 | 193 | // v = vapor_var(500, 'foo'); v('bar'); v() ==> 'bar' 194 | // (after 500ms) v() ==> 'foo' 195 | E.vapor_var = (millisec, default_val) => { 196 | let val 197 | const recover = () => {val = default_val} 198 | const [recover_later] = deferred_procs([recover, millisec]) 199 | const obj = new_val => 200 | (new_val === undefined ? val : (val = new_val, recover_later())) 201 | recover(); return obj 202 | } 203 | 204 | E.make_speedometer = (interval_sec, premature_sec) => { 205 | let t0, k0, t1, k1 // t0 = origin, t1 = next origin 206 | let the_latest = null 207 | const reset = () => {[t0, k0, t1, k1] = [Date.now(), NaN, null, null]} 208 | const per_sec = k => { 209 | const t = Date.now(), ready = !isNaN(k0), dt_sec = () => (t - t0) / 1000 210 | !ready && (dt_sec() >= premature_sec) && ([t0, k0, t1, k1] = [t, k, t, k]) 211 | ready && (t - t1 >= interval_sec * 1000) && ([t0, k0, t1, k1] = [t1, k1, t, k]) 212 | const ret = (k - k0) / dt_sec() 213 | return ready && !isNaN(ret) && (ret < Infinity) && (the_latest = ret) 214 | } 215 | const latest = () => the_latest 216 | reset(); per_sec(0); return {reset, per_sec, latest} 217 | } 218 | 219 | // make unique and JSON-safe ID for the pushed value, that can be popped only once. 220 | // (ex.) 221 | // id_a = onetime_storer.push('a'); id_b = onetime_storer.push('b') 222 | // onetime_storer.pop(id_b) ==> 'b' 223 | // onetime_storer.pop(id_b) ==> undefined 224 | function make_onetime_storer() { 225 | let next_id = 0, value_for = {} 226 | const object = { 227 | push: val => {const id = next_id++; value_for[id] = val; return id}, 228 | pop: id => {const val = value_for[id]; delete value_for[id]; return val}, 229 | } 230 | return object 231 | } 232 | const onetime_storer = make_onetime_storer() 233 | 234 | // make a promise that can be resolved with ID 235 | // (ex.) 236 | // p = make_promise_with_id(); p.promise.then(console.log) 237 | // resolve_promise_with_id(p.id, 99) // ==> 99 is printed on console 238 | E.make_promise_with_id = () => { 239 | const {promise, resolve, reject} = Promise_withResolvers() 240 | const id = onetime_storer.push(resolve) 241 | return {promise, id} 242 | } 243 | E.resolve_promise_with_id = (id, val) => { 244 | const resolve = onetime_storer.pop(id); resolve && resolve(val) 245 | } 246 | function Promise_withResolvers() { 247 | // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers 248 | let resolve, reject; 249 | const promise = new Promise((res, rej) => {resolve = res; reject = rej}) 250 | return {promise, resolve, reject} 251 | } 252 | 253 | // for engine (chiefly) 254 | E.common_header_length = (a, b, strictly) => { 255 | const same_move = (x, y) => (!!x.is_black === !!y.is_black && x.move === y.move) 256 | const eq = strictly ? ((x, y) => (x === y)) : same_move 257 | const k = a.findIndex((x, i) => !eq(x, b[i] || {})) 258 | return (k >= 0) ? k : a.length 259 | } 260 | E.each_line = (f) => { 261 | let buf = '' 262 | return chunk => { 263 | const a = chunk.toString().split(/\r?\n/), rest = a.pop() 264 | !empty(a) && (a[0] = buf + a[0], buf = '', a.forEach(f)) 265 | buf += rest 266 | } 267 | } 268 | E.set_error_handler = (process, handler) => { 269 | ['stdin', 'stdout', 'stderr'].forEach(k => process[k].on('error', handler)) 270 | process.on('exit', handler) 271 | } 272 | 273 | E.exec_command = (com, f) => { 274 | const callback = (err, stdout, stderror) => !err && f && f(stdout) 275 | require('child_process').exec(com, callback) 276 | } 277 | 278 | E.initial_sanity = 10 279 | E.sanity_range = [0, 20] 280 | 281 | // humanSL profiles 282 | const hsl_dks = [...E.seq_from_to(1, 9).map(z => `${z}d`).reverse(), 283 | ...E.seq_from_to(1, 20).map(z => `${z}k`)] 284 | const hsl_years = [1800, 1850, 1900, 1950, 1960, 1970, 1980, 1990, 2000, 285 | 2005, 2010, 2015, 2017, 2019, 2021, 2023] 286 | function hsl_prepend(header, ary) {return ary.map(z => header + z)} 287 | E.humansl_rank_profiles = hsl_prepend('rank_', hsl_dks) 288 | E.humansl_preaz_profiles = hsl_prepend('preaz_', hsl_dks) 289 | E.humansl_proyear_profiles = hsl_prepend('proyear_', hsl_years) 290 | E.humansl_policy_keys = [ 291 | 'default_policy', 292 | 'humansl_stronger_policy', 'humansl_weaker_policy', 293 | 'humansl_scan', 294 | ] 295 | 296 | // avoid letters for keyboard operation in renderer.js 297 | const normal_tag_letters = 'defghijklmnorstuy' 298 | const last_loaded_element_tag_letter = '.' 299 | const start_moves_tag_letter = "'" 300 | const endstate_diff_tag_letter = "/" 301 | const branching_tag_letter = ":", unnamed_branch_tag_letter = "^" 302 | const ladder_tag_letter = "=" 303 | const tag_letters = normal_tag_letters + last_loaded_element_tag_letter + 304 | start_moves_tag_letter + endstate_diff_tag_letter + 305 | branching_tag_letter + unnamed_branch_tag_letter + ladder_tag_letter 306 | const implicit_tag_letters = endstate_diff_tag_letter + branching_tag_letter 307 | + last_loaded_element_tag_letter + ladder_tag_letter 308 | function exclude_implicit_tags(tags) { 309 | return implicit_tag_letters.split('').reduce((acc, t) => acc.replaceAll(t, ''), tags) 310 | } 311 | const common_constants = { 312 | normal_tag_letters, last_loaded_element_tag_letter, 313 | start_moves_tag_letter, endstate_diff_tag_letter, 314 | branching_tag_letter, unnamed_branch_tag_letter, 315 | ladder_tag_letter, 316 | tag_letters, exclude_implicit_tags, 317 | } 318 | 319 | module.exports = E.merge(E, common_constants) 320 | -------------------------------------------------------------------------------- /src/window.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | ////////////////////////////////////// 4 | // exports 5 | 6 | let electron, store, set_stored 7 | 8 | module.exports = (...a) => { 9 | [electron, store, set_stored] = a 10 | return { 11 | window_prop, 12 | window_for_id, 13 | get_windows, 14 | get_new_window, 15 | webPreferences, 16 | new_window, 17 | renderer, 18 | renderer_with_window_prop, 19 | } 20 | } 21 | 22 | ////////////////////////////////////// 23 | // window 24 | 25 | let windows = [], last_window_id = -1 26 | 27 | function window_prop(win) { // fixme: adding private data impolitely 28 | const private_key = 'lizgoban_window_prop' 29 | return win[private_key] || (win[private_key] = { 30 | window_id: -1, board_type: '', previous_board_type: '' 31 | }) 32 | } 33 | 34 | function window_for_id(window_id) { 35 | return get_windows().find(win => window_prop(win).window_id === window_id) 36 | } 37 | 38 | function get_windows() { 39 | return windows = windows.filter(win => !win.isDestroyed()) 40 | } 41 | 42 | function get_new_window(file_name, opt) { 43 | const win = new electron.BrowserWindow(opt) 44 | win.loadURL('file://' + __dirname + '/' + file_name) 45 | return win 46 | } 47 | 48 | const webPreferences = { 49 | nodeIntegration: true, 50 | contextIsolation: false, 51 | } 52 | function new_window(default_board_type) { 53 | const window_id = ++last_window_id, conf_key = 'window.id' + window_id 54 | const ss = electron.screen.getPrimaryDisplay().size 55 | const {board_type, previous_board_type, position, size, maximized} 56 | = store.get(conf_key) || {} 57 | const [x, y] = position || [0, 0] 58 | const [width, height] = size || [ss.height, ss.height * 0.6] 59 | const win = get_new_window('index.html', 60 | {x, y, width, height, webPreferences, show: false}) 61 | const prop = window_prop(win) 62 | merge(prop, { 63 | window_id, board_type: board_type || default_board_type, previous_board_type 64 | }) 65 | windows.push(win) 66 | maximized && win.maximize() 67 | win.on('close', () => set_stored(conf_key, { 68 | board_type: prop.board_type, previous_board_type: prop.previous_board_type, 69 | position: win.getPosition(), size: win.getSize(), maximized: win.isMaximized(), 70 | })) 71 | // Hidden sgf_from_image windows prevent 'window-all-closed' event. 72 | // So we need an explicit check here to trigger app.quit(). 73 | win.on('closed', () => empty(get_windows()) && electron.app.quit()) 74 | win.once('ready-to-show', () => win.show()) 75 | return win 76 | } 77 | 78 | ////////////////////////////////////// 79 | // renderer 80 | 81 | function renderer(channel, ...args) {renderer_gen(channel, false, ...args)} 82 | function renderer_with_window_prop(channel, ...args) { 83 | renderer_gen(channel, true, ...args) 84 | } 85 | function renderer_gen(channel, win_prop_p, ...args) { 86 | // Caution [2018-08-08] [2019-06-20] 87 | // (1) JSON.stringify(NaN) is 'null' and JSON.stringify({foo: undefined}) is '{}'. 88 | // (2) IPC converts {foo: NaN} and {bar: undefined} to {}. 89 | // example: 90 | // [main.js] renderer('foo', {bar: NaN, baz: null, qux: 3, quux: undefined}) 91 | // [renderer.js] ipc.on('foo', (e, x) => (tmp = x)) 92 | // [result] tmp is {baz: null, qux: 3} 93 | get_windows().forEach(win => win.webContents 94 | .send(channel, ...(win_prop_p ? [window_prop(win)] : []), 95 | ...args)) 96 | } 97 | -------------------------------------------------------------------------------- /tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/tree.png --------------------------------------------------------------------------------