├── .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 | - `` in `index.html`
51 | - `set_match_param(...)` in `main.js`
52 | - `get_move_etc(...)` in `weak_move.js`
53 |
--------------------------------------------------------------------------------
/OBSOLETE.md:
--------------------------------------------------------------------------------
1 | The following descriptions are obsolete. Though they may still work partially, they will be deleted in future without warnings.
2 |
3 | #### To attach LizGoban to [Sabaki](https://sabaki.yichuanshen.de/) as subwindows (obsolete, may be deleted in future):
4 |
5 | 1. Build a [customized Sabaki](https://github.com/kaorahi/Sabaki/tree/dump_state2) in "dump_state2" branch.
6 | 2. Put Sabaki binary as "external/sabaki".
7 | 3. Start LizGoban.
8 | 4. Click "Attach Sabaki" in "Tool" menu of LizGoban and wait for Sabaki window.
9 | 5. Put a stone on Sabaki and see it appears on LizGoban.
10 |
--------------------------------------------------------------------------------
/build_with/Makefile:
--------------------------------------------------------------------------------
1 | DOC = extra/doc
2 | SAMPLE = extra/sample
3 | PD_HEADER = pandoc_header
4 |
5 | README = $(DOC)/README.html
6 | REL_NOTE = $(DOC)/release.html
7 | SCR_IMG = $(DOC)/screen.gif
8 | MTCH_IMG = $(DOC)/match.png
9 | FACE_IMG = $(DOC)/faces.png
10 | KATA_DOC_DIR = $(DOC)/KataGo
11 |
12 | BIN_DIR = bin
13 | KATA_DIR = $(BIN_DIR)/win/katago
14 | KATA_MODEL_DIR = $(BIN_DIR)/common
15 | KATA_MODEL_FILE = $(KATA_MODEL_DIR)/katanetwork.gz
16 | KATA_HUMANMODEL_FILE = $(KATA_MODEL_DIR)/kata_humanmodel.gz
17 | KATA_MODEL_D_FILE = $(KATA_DOC_DIR)/katanetwork_license.txt
18 |
19 | TARGETS = $(README) $(REL_NOTE) $(SCR_IMG) $(MTCH_IMG) $(FACE_IMG)
20 |
21 | PANDOC = pandoc -H $(PD_HEADER)
22 |
23 | local: $(TARGETS)
24 |
25 | all: katago local img
26 |
27 | clean:
28 | rm -f $(TARGETS)
29 |
30 | $(DOC):
31 | mkdir -p $@
32 |
33 | $(REL_NOTE): release.md $(PD_HEADER) $(DOC)
34 | $(PANDOC) $< -M pagetitle='Release Note' -o $@
35 |
36 | $(README): ../README.md $(PD_HEADER) $(DOC)
37 | $(PANDOC) $< -M pagetitle='README' -o $@
38 |
39 | $(SCR_IMG): ../screen.gif $(DOC)
40 | convert $<'[0]' $@
41 |
42 | $(MTCH_IMG): ../match.png $(DOC)
43 | cp -f $< $@
44 |
45 | $(FACE_IMG): ../faces.png $(DOC)
46 | cp -f $< $@
47 |
48 | #######################################
49 | # katago
50 |
51 | KATA_URL_BASE = https://github.com/lightvector/KataGo/releases/download
52 | # KATA_URL1 = $(KATA_URL_BASE)/v1.15.3/katago-v1.15.3-eigen-windows-x64.zip
53 | KATA_URL2 = $(KATA_URL_BASE)/v1.16.0/katago-v1.16.0-eigenavx2-windows-x64.zip
54 | KATA_URL3 = $(KATA_URL_BASE)/v1.16.0/katago-v1.16.0-opencl-windows-x64.zip
55 | KATA_MODEL_URL = https://media.katagotraining.org/uploaded/networks/models/kata1/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz
56 | KATA_HUMANMODEL_URL = $(KATA_URL_BASE)/v1.15.0/b18c384nbt-humanv0.bin.gz
57 | KATA_MODEL_D_URL = https://katagotraining.org/network_license/
58 |
59 | katago: $(KATA_DIR) $(KATA_MODEL_FILE)
60 |
61 | $(KATA_DIR):
62 | mkdir -p $@
63 | # \wget -O tmp_kata1.zip $(KATA_URL1)
64 | \wget -O tmp_kata2.zip $(KATA_URL2)
65 | \wget -O tmp_kata3.zip $(KATA_URL3)
66 | # unzip -o tmp_kata1.zip -d $@ && cd $@ && mv katago.exe katago-eigen.exe
67 | unzip -o tmp_kata2.zip -d $@ && cd $@ && mv katago.exe katago-eigenavx2.exe
68 | unzip -o tmp_kata3.zip -d $@ && cd $@ && mv katago.exe katago-opencl.exe
69 |
70 | $(KATA_MODEL_FILE):
71 | mkdir -p $(KATA_MODEL_DIR)
72 | mkdir -p $(KATA_DOC_DIR)
73 | \wget -O $(KATA_MODEL_FILE) $(KATA_MODEL_URL)
74 | \wget -O $(KATA_HUMANMODEL_FILE) $(KATA_HUMANMODEL_URL)
75 | \wget -O - $(KATA_MODEL_D_URL) | pandoc -f html -t plain -o $(KATA_MODEL_D_FILE)
76 |
77 | #######################################
78 | # facial stone images
79 |
80 | # cf. https://www.asahi-net.or.jp/~hk6t-itu/igo/goisisan.html
81 | GOISI_URL = https://www.asahi-net.or.jp/~hk6t-itu/igo/image/
82 | GOISI_COLORS = k s
83 | GOISI_INDICES = 4 5 7 8 9 10 11 14 15 16
84 |
85 | img:
86 | mkdir -p $@
87 | (for c in $(GOISI_COLORS); do for i in $(GOISI_INDICES); do echo $(GOISI_URL)/goisi_$$c$$i.png; done; done) | wget -i - -P $@
88 |
--------------------------------------------------------------------------------
/build_with/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "max_cached_engines": 2,
3 | "sound_file": {
4 | "stone": ["put02.mp3", "put03.mp3", "put04.mp3", "put05.mp3"],
5 | "capture": ["capture18.mp3", "capture20.mp3", "capture58.mp3"],
6 | "pass": ["jara62.mp3"]
7 | },
8 | "face_image_rule": [
9 | [-0.8, "goisi_k4.png", "goisi_s4.png"],
10 | [-0.4, "goisi_k8.png", "goisi_s8.png"],
11 | [0.00, "goisi_k7.png", "goisi_s7.png"],
12 | [0.30, "goisi_k11.png", "goisi_s11.png"],
13 | [0.90, "goisi_k10.png", "goisi_s10.png"],
14 | [1.00, "goisi_k16.png", "goisi_s16.png"]
15 | ],
16 | "face_image_diff_rule": [
17 | [-1.0, "goisi_k15.png", "goisi_s15.png"],
18 | [-0.5, "goisi_k9.png", "goisi_s9.png"],
19 | [0.50, null, null],
20 | [1.00, "goisi_k5.png", "goisi_s5.png"],
21 | [2.00, "goisi_k14.png", "goisi_s14.png"]
22 | ],
23 | "preset": [
24 | {
25 | "label": "KataGo",
26 | "accelerator": "F1",
27 | "engine": ["katago/katago-eigenavx2", "gtp",
28 | "-override-config",
29 | "analysisPVLen=50, defaultBoardSize=19, homeDataDir=., logAllGTPCommunication=false, logSearchInfo=false"]
30 | },
31 | {
32 | "label": "Human-like Analysis",
33 | "accelerator": "F2",
34 | "engine": ["katago/katago-eigenavx2", "gtp",
35 | "-human-model", "katago/human_model.bin.gz",
36 | "-override-config", "humanSLProfile=",
37 | "-override-config",
38 | "analysisPVLen=50, defaultBoardSize=19, homeDataDir=., logAllGTPCommunication=false, logSearchInfo=false"]
39 | },
40 | {
41 | "label": "Human-like Play",
42 | "accelerator": "F3",
43 | "engine": ["katago/katago-eigenavx2", "gtp",
44 | "-config", "katago/gtp_human5k_example.cfg",
45 | "-human-model", "katago/human_model.bin.gz",
46 | "-override-config", "humanSLProfile=",
47 | "-override-config",
48 | "analysisPVLen=50, defaultBoardSize=19, homeDataDir=., logAllGTPCommunication=false, logSearchInfo=false"]
49 | },
50 | {
51 | "label": "(GPU) KataGo",
52 | "accelerator": "F4",
53 | "engine": ["katago/katago-opencl", "gtp",
54 | "-override-config",
55 | "analysisPVLen=50, defaultBoardSize=19, homeDataDir=., logAllGTPCommunication=false, logSearchInfo=false"]
56 | },
57 | {
58 | "label": "(GPU) Human-like Analysis",
59 | "accelerator": "F5",
60 | "engine": ["katago/katago-opencl", "gtp",
61 | "-human-model", "katago/human_model.bin.gz",
62 | "-override-config", "humanSLProfile=",
63 | "-override-config",
64 | "analysisPVLen=50, defaultBoardSize=19, homeDataDir=., logAllGTPCommunication=false, logSearchInfo=false"]
65 | },
66 | {
67 | "label": "(GPU) Human-like Play",
68 | "accelerator": "F6",
69 | "engine": ["katago/katago-opencl", "gtp",
70 | "-config", "katago/gtp_human5k_example.cfg",
71 | "-human-model", "katago/human_model.bin.gz",
72 | "-override-config", "humanSLProfile=",
73 | "-override-config",
74 | "analysisPVLen=50, defaultBoardSize=19, homeDataDir=., logAllGTPCommunication=false, logSearchInfo=false"]
75 | }
76 | ]
77 | }
78 |
--------------------------------------------------------------------------------
/build_with/pandoc_header:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/build_with/release.md:
--------------------------------------------------------------------------------
1 | [comment]: # -*- coding: utf-8 -*-
2 |
3 | # Release notes
4 |
5 | ## LizGoban 0.9.0
6 |
7 | Highlights:
8 |
9 | * Upgrade KataGo to [1.16.0](https://github.com/lightvector/KataGo/releases/tag/v1.16.0).
10 | * Add visualization of AI's search tree. (`Tool > Plot MCTS tree`) [sample](https://kaorahi.github.io/visual_MCTS/)
11 | * Add rank estimation feature if a human model is available:
12 | * rank estimation (9d, 3d, 1k, 6k, or 15k, by default. Check `Edit > Preferences > Finer dan/kyu scan` for finer but slower estimation.)
13 | * each rank's preferences in the winrate graph
14 | * automatic adjustment of the human-style profile in match vs. AI.
15 | * [Experimental] Show the search tree for ["if players try to capture/rescue this stone"](https://github.com/lightvector/KataGo/issues/1031#issuecomment-2746727449) by shift + double-click. You need to specify KataGo's option `-human-model` for good results. (In the all-in-one package for Windows, choose "Human-like Analysis" from "Preset" menu.) [ref](https://github.com/kaorahi/visual_MCTS/tree/master/sample4)
16 |
17 | Further updates:
18 |
19 | * Add board and stone images to the all-in-one package for Windows.
20 | * Add board position copy-paste.
21 | 1. Alt+drag to select the source region.
22 | 2. `Edit > Flip / rotate / etc. > copy stones`
23 | 3. Alt+drag to select the destination region.
24 | 4. `Edit > Flip / rotate / etc. > paste`
25 | * Add tsumego frame without ko threats. (`Tool > Tsumego frame`)
26 | * Add "Next move quiz" to View menu.
27 |
28 | Incompatibilities with 0.8.*:
29 |
30 | * Upgrade libraries (Electron 36, etc.). So you may need to do "npm install" again if you use LizGoban from the command line.
31 |
32 | ### Human-style features
33 |
34 | Choose "Human-like Analysis" or "Human-like Play" from "Preset" menu and refer to "KataGo" section in "Help" menu for details.
35 |
36 | Thanks to [dfannius](https://github.com/dfannius); this analysis feature is a variation of his "policy heatmap".
37 |
38 | You can also play against the "spar" AI. Designed for practice, it focuses not on winning but on creating skill-testing situations for players of specific DAN or KYU ranks. Let's knock it out!
39 |
40 | 1. From "Preset" menu, choose "Human-like Play". ("Human-like Analysis" is also ok.)
41 | 2. From "File" menu, choose "Match vs. AI".
42 | 3. Select "spar" in "vs." pulldown menu.
43 | 4. Adjust the profile slider. (20k-9d)
44 | 5. Click the board to place the first black stone, or click "start AI's turn" button to let the AI play black.
45 |
46 | ### To use it on 64bit Windows immediately
47 |
48 | Just download the all-in-one package (`LizGoban-*_win_*.zip`), unzip it, and double-click `LizGoban *.exe`. You do not need installation, configuration, additional downloads, and so on. Its file size is due to the built-in engine:
49 |
50 | * [KataGo](https://github.com/lightvector/KataGo/releases/) (eigenavx2, opencl)
51 | * [18 block network](https://katagotraining.org/networks/) (kata1-b18c384nbt-s9996)
52 | * [human-trained network](https://github.com/lightvector/KataGo/releases/tag/v1.15.0) (b18c384nbt-humanv0)
53 |
54 | You can switch KataGo versions (CPU and GPU) by [Preset] menu in LizGoban. The first run of the GPU version may take a long time (1 hour on a low-spec machine, for example) for its initial tuning. You can also choose "Human-like Analysis" or "Human-like Play" from [Preset] menu. Refer to "KataGo" section in [Help] menu for details.
55 |
56 | ### To customize it on 64bit Windows
57 |
58 | If you want to use another network (aka. model, weights), you can simply click the Engine menu and select "Load network weights". Additionally, you can modify the `config.json` file for more flexible configuration. See README for details.
59 |
60 | ### To use it on other platforms (Mac, Linux, ...)
61 |
62 | Download the source code and see `README.md`.
63 |
64 | ### Links
65 |
66 | [Project Home](https://github.com/kaorahi/lizgoban) /
67 | [License (GPL3)](https://github.com/kaorahi/lizgoban/blob/master/LICENSE.txt)
68 |
69 | Note that some external resources are also packaged into *.zip together with LizGoban itself. The license of LizGoban is not applied to them, of course.
70 |
71 | * engines and neural networks: [KataGo](https://github.com/lightvector/KataGo/)
72 | * facial stone images: [Goisisan](https://www.asahi-net.or.jp/~hk6t-itu/igo/goisisan.html)
73 | * stone sounds: extracted from [OmnipotentEntity's Discord post](https://discord.com/channels/417022162348802048/417038123822743552/1251545825226526792)
74 |
--------------------------------------------------------------------------------
/build_with/splash.bmp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/build_with/splash.bmp
--------------------------------------------------------------------------------
/external/README.txt:
--------------------------------------------------------------------------------
1 | directory for external tools
2 |
--------------------------------------------------------------------------------
/external/leela0110.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/ruby
2 |
3 | # Wrapper for Leela 0.11.0 (not Leela Zero)
4 | # This Ruby script wraps leela_gtp as if it accepts lz-analyze.
5 |
6 | # To use Leela 0.11.0 on LizGoban,
7 | # add the following item into "preset" in your config.json.
8 |
9 | # example of config.json:
10 | #
11 | # {
12 | # ...,
13 | # "preset": [
14 | # ...,
15 | # {"label": "leela 0.11.0", "engine": ["leela0110.rb"]},
16 | # ...
17 | # ],
18 | # ...
19 | # }
20 |
21 | require "open3"
22 |
23 | $leela_in, $leela_out = *Open3.popen2e('leela_gtp -g')
24 | $leela_in.sync = STDOUT.sync = STDERR.sync = true
25 |
26 | ######################
27 | # leela_out
28 |
29 | $order = 0
30 |
31 | def c(x)
32 | (x.to_f * 100).to_i
33 | end
34 |
35 | Thread.new {
36 | $leela_out.each_line{|line|
37 | case line
38 | when /(\w+)\s*->\s*(\d+).*\(W:\s*([-0-9.]+)%\).*PV:\s*(.+)/
39 | _, move, visits, winrate, prior, pv = *$~
40 | # print ' ' if $order > 0
41 | print "info move #{move} visits #{visits} winrate #{c(winrate)} order #{$order} pv #{pv}"
42 | $order += 1
43 | when /.* feature weights loaded, .* patterns/
44 | STDERR.puts "GTP ready" # mimic KataGo's start-up message for LizGoban
45 | else
46 | print "\n" if $order > 0
47 | $order = 0
48 | print line
49 | end
50 | }
51 | }
52 |
53 | ######################
54 | # leela_in
55 |
56 | $analyzing = false
57 | $analyzer = Thread.new {
58 | Thread.stop
59 | loop {
60 | $leela_in.puts 'time_left b 0 0'
61 | sleep 1
62 | $analyzing or Thread.stop
63 | $leela_in.puts 'name'
64 | }
65 | }
66 |
67 | STDIN.each{|line|
68 | case line
69 | when /^(.*)lz-analyze/
70 | $leela_in.puts "#{$1}name"
71 | $analyzing = true
72 | $analyzer.run
73 | else
74 | $analyzing = false
75 | $leela_in.print line
76 | end
77 | }
78 |
--------------------------------------------------------------------------------
/faces.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/faces.png
--------------------------------------------------------------------------------
/lizgoban_windows.ps1:
--------------------------------------------------------------------------------
1 | $conf = "config.json"
2 | $command = "npm start"
3 |
4 | if (Test-Path $conf) {
5 | $command = "$command -- -c $conf"
6 | }
7 |
8 | Start-Process -NoNewWindow -FilePath "cmd.exe" -ArgumentList "/c $command"
9 |
--------------------------------------------------------------------------------
/lizgoban_windows.vbs:
--------------------------------------------------------------------------------
1 | Const conf = "config.json"
2 | command = "npm start"
3 |
4 | Set fso = CreateObject("Scripting.FileSystemObject")
5 | If fso.FileExists(conf) Then
6 | command = command & " -- -c " & conf
7 | End If
8 |
9 | CreateObject("WScript.Shell").Run command, 0
10 |
--------------------------------------------------------------------------------
/match.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/match.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "LizGoban",
3 | "version": "0.9.0",
4 | "description": "An analysis tool of the game Go with Leela Zero and KataGo",
5 | "author": "kaorahi ",
6 | "license": "GPL-3.0",
7 | "main": "./src/main.js",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/kaorahi/lizgoban"
11 | },
12 | "dependencies": {
13 | "@viz-js/viz": "^3.11.0",
14 | "@sabaki/sgf": "^3.4.7",
15 | "electron-store": "^8.0.2",
16 | "iconv-lite": "^0.6.3",
17 | "jschardet": "^3.0.0",
18 | "tmp": "^0.2.1",
19 | "twgl.js": "^5.0.4",
20 | "xyz2sgf": "^0.1.0"
21 | },
22 | "devDependencies": {
23 | "electron": "^36.3.1",
24 | "electron-builder": "^26.0.12"
25 | },
26 | "build": {
27 | "files": [
28 | "src/{*.js,*.html,*.css,*.png}",
29 | "src/sgf_from_image/{*.js,*.html,*.css,*.png}",
30 | "src/mcts/{*.js,*.html}"
31 | ],
32 | "extraFiles": [
33 | {
34 | "from": "sound",
35 | "to": "resources/external",
36 | "filter": [
37 | "*.mp3"
38 | ]
39 | },
40 | {
41 | "from": "build_with/img",
42 | "to": "resources/external",
43 | "filter": [
44 | "board.png",
45 | "black.png",
46 | "white.png",
47 | "goisi_*.png"
48 | ]
49 | }
50 | ],
51 | "linux": {
52 | "target": "AppImage",
53 | "category": "Game",
54 | "extraFiles": [
55 | {
56 | "from": "build_with/bin/linux/leelaz",
57 | "to": "resources/external",
58 | "filter": [
59 | "leelaz",
60 | "network.gz"
61 | ]
62 | }
63 | ]
64 | },
65 | "win": {
66 | "target": "portable"
67 | },
68 | "portable": {
69 | "splashImage": "build_with/splash.bmp"
70 | }
71 | },
72 | "scripts": {
73 | "start": "electron .",
74 | "build_lin": "electron-builder -l --x64",
75 | "build_win": "electron-builder -w --x64"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/screen.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/screen.gif
--------------------------------------------------------------------------------
/sound/README.md:
--------------------------------------------------------------------------------
1 | extracted from [OmnipotentEntity's Discord post](https://discord.com/channels/417022162348802048/417038123822743552/1251545825226526792)
2 |
--------------------------------------------------------------------------------
/sound/capture18.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/capture18.mp3
--------------------------------------------------------------------------------
/sound/capture20.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/capture20.mp3
--------------------------------------------------------------------------------
/sound/capture58.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/capture58.mp3
--------------------------------------------------------------------------------
/sound/jara62.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/jara62.mp3
--------------------------------------------------------------------------------
/sound/put02.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/put02.mp3
--------------------------------------------------------------------------------
/sound/put03.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/put03.mp3
--------------------------------------------------------------------------------
/sound/put04.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/put04.mp3
--------------------------------------------------------------------------------
/sound/put05.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/sound/put05.mp3
--------------------------------------------------------------------------------
/src/ai.js:
--------------------------------------------------------------------------------
1 | // ai.js: abstraction of engines
2 |
3 | const PATH = require('path')
4 | const original_create_leelaz = require('./engine.js').create_leelaz
5 |
6 | // See "engine cache" section for leelaz objects in this file.
7 | function create_leelaz() {return create_leelaz_proxy()}
8 |
9 | /////////////////////////////////////////////////
10 | // initialize
11 |
12 | // leelaz
13 | let leelaz = create_leelaz(), leelaz_for_black = leelaz
14 | let leelaz_for_white = null
15 |
16 | // from powered_goban.js
17 | let suggest_handler
18 | function set_handlers(h) {({suggest_handler} = h)}
19 |
20 | /////////////////////////////////////////////////
21 | // leelaz
22 |
23 | function start_leelaz(start_args) {
24 | leelaz.start(with_handlers(start_args))
25 | }
26 | function update_leelaz() {leelaz.update()}
27 | function restart(h, new_weight_p) {
28 | if (!h && !new_weight_p) {leelaz.force_restart(); return}
29 | const cooked = h && with_handlers(h)
30 | const error_handler =
31 | (leelaz === leelaz_for_white) ? invalid_weight_for_white : do_nothing
32 | leelaz.restart(new_weight_p ? {...cooked, error_handler} : cooked)
33 | }
34 | function set_board(hist, aux) {
35 | // see set_board in engine.js for "aux".
36 | const set_it = z => z.set_board(hist, aux)
37 | each_leelaz(set_it)
38 | }
39 | function genmove(sec, callback) {leelaz.genmove(sec, callback)}
40 | function genmove_analyze(sec, callback) {leelaz.genmove_analyze(sec, callback)}
41 | function cancel_past_requests() {each_leelaz(z => z.clear_leelaz_board())}
42 | function kill_all_leelaz() {each_leelaz(z => z.kill())}
43 | function set_pondering(pausing, busy) {
44 | const pondering = !pausing && !busy
45 | const b = (leelaz === leelaz_for_black)
46 | leelaz_for_black.set_pondering(pondering && b)
47 | leelaz_for_white && leelaz_for_white.set_pondering(pondering && !b)
48 | }
49 | function all_start_args() {
50 | const f = lz => lz && lz.start_args()
51 | return {black: f(leelaz_for_black), white: f(leelaz_for_white)}
52 | }
53 | function restore_all_start_args({black, white}) {
54 | unload_leelaz_for_white(); leelaz.kill() // white must be first
55 | leelaz.start(black); white && start_engine_for_white(white)
56 | }
57 | function leelaz_weight_file(white_p) {
58 | const lz = (white_p && leelaz_for_white) || leelaz_for_black
59 | return lz && lz.get_weight_file()
60 | }
61 |
62 | function each_leelaz(f) {
63 | [leelaz_for_black, leelaz_for_white,
64 | ].forEach(z => z && f(z))
65 | }
66 | function with_handlers(h) {
67 | const more = h.ready_handler ?
68 | {ready_handler: (...a) => {backup(); h.ready_handler(...a)}} : {}
69 | return {suggest_handler, command_failure_handler, ...h, ...more}
70 | }
71 |
72 | function katago_p() {return leelaz_for_this_turn().is_katago()}
73 | function is_gorule_supported() {
74 | return leelaz_for_this_turn().is_supported('kata-set-rules')
75 | }
76 | function is_moves_ownership_supported() {
77 | return leelaz_for_this_turn().is_supported('movesOwnership')
78 | }
79 | function is_sub_model_humanSL_supported() {
80 | return leelaz_for_this_turn().is_supported('sub_model_humanSL')
81 | }
82 |
83 | let analysis_region = null
84 | function update_analysis_region(region) {
85 | analysis_region = region; each_leelaz(apply_current_analysis_region)
86 | }
87 | function apply_current_analysis_region(lz) {lz.update_analysis_region(analysis_region)}
88 |
89 | function set_instant_analysis(instant_p) {each_leelaz(lz => lz.set_instant_analysis(instant_p))}
90 |
91 | /////////////////////////////////////////////////
92 | // another leelaz for white
93 |
94 | function leelaz_for_white_p() {return !!leelaz_for_white}
95 | function swap_leelaz_for_black_and_white() {
96 | if (!leelaz_for_white) {return}
97 | [leelaz_for_black, leelaz_for_white] = [leelaz_for_white, leelaz_for_black]
98 | backup(); switch_leelaz()
99 | }
100 | function switch_to_random_leelaz(percent) {
101 | switch_leelaz(xor(is_bturn(), Math.random() < percent / 100))
102 | }
103 | function set_engine_for_white(command_args, preset_label, wait_for_startup) {
104 | const [leelaz_command, ...leelaz_args] = command_args
105 | const start_args = {...leelaz_for_black.start_args(), weight_file: null,
106 | leelaz_command, leelaz_args, preset_label, wait_for_startup}
107 | start_engine_for_white(start_args)
108 | }
109 | function start_engine_for_white(start_args) {
110 | unload_leelaz_for_white()
111 | leelaz_for_white = create_leelaz()
112 | leelaz_for_white.start(start_args)
113 | switch_leelaz()
114 | }
115 | function unload_leelaz_for_white() {
116 | switch_to_another_leelaz(leelaz_for_black)
117 | leelaz_for_white && leelaz_for_white.kill(); leelaz_for_white = null
118 | }
119 | function switch_leelaz(bturn) {
120 | return switch_to_another_leelaz(leelaz_for_this_turn(bturn))
121 | }
122 | // We need to use "leelaz_for_this_turn()" instead of "leelaz"
123 | // between P.set_board() and AI.switch_leelaz() in set_board() in main.js.
124 | function leelaz_for_this_turn(bturn) {
125 | return (bturn === undefined ? is_bturn() : bturn) ?
126 | leelaz_for_black : (leelaz_for_white || leelaz)
127 | }
128 | function load_weight_file(weight_file, white_p) {
129 | set_pondering(false)
130 | const lz = white_p ? (leelaz_for_white || (leelaz_for_white = create_leelaz()))
131 | : leelaz_for_black
132 | const sa = lz.start_args() || leelaz_for_black.start_args()
133 | const {label} = sa.preset_label, preset_label = {label, modified_p: true}
134 | lz.restart({...sa, preset_label, weight_file})
135 | switch_leelaz()
136 | }
137 |
138 | // internal
139 |
140 | function switch_to_another_leelaz(next_leelaz) {
141 | return next_leelaz && next_leelaz !== leelaz && (leelaz = next_leelaz)
142 | }
143 |
144 | /////////////////////////////////////////////////
145 | // misc.
146 |
147 | function engine_info() {
148 | // fixme: duplication with all_start_args()
149 | const f = lz => {
150 | if (!lz || !lz.start_args()) {return null}
151 | const {leelaz_command, leelaz_args, preset_label} = lz.start_args()
152 | const weight_file = lz.get_weight_file()
153 | const {label, modified_p} = preset_label || {}
154 | const preset_label_text = `${label || ''}` +
155 | (modified_p ?
156 | `{${snip_text(PATH.basename(weight_file || ''), 20, 5, '..')}}` : '')
157 | return {leelaz_command, leelaz_args, is_ready: lz.is_ready(), preset_label_text,
158 | humansl_profile: lz.humansl_profile(),
159 | weight_file, network_size: lz.network_size()}
160 | }
161 | const cur_lz = leelaz_for_this_turn(), cur_lz_komi = cur_lz.get_komi()
162 | return {engine_komi: valid_numberp(cur_lz_komi) ? cur_lz_komi : '?',
163 | leelaz_for_white_p: leelaz_for_white_p(), current: f(cur_lz),
164 | really_current: f(leelaz), // for switch_to_random_leelaz
165 | black: f(leelaz_for_black), white: f(leelaz_for_white)}
166 | }
167 |
168 | function current_preset_label() {
169 | const info = engine_info().really_current, {humansl_profile} = info
170 | return info.preset_label_text + (humansl_profile ? `[${humansl_profile}]` : '')
171 | }
172 |
173 | function startup_log() {return leelaz_for_this_turn().startup_log()}
174 |
175 | function different_komi_for_black_and_white() {
176 | return leelaz_for_white &&
177 | (leelaz_for_black.get_komi() !== leelaz_for_white.get_komi())
178 | }
179 |
180 | function humansl_profile_gen(lz, profile) {
181 | return !!lz &&
182 | (profile === undefined ?
183 | !!lz.is_supported('humanSLProfile') && lz.humansl_profile() :
184 | lz.humansl_request_profile(profile, humansl_profile_request_callback))
185 | }
186 | function humansl_profile(profile) {
187 | return humansl_profile_gen(leelaz, profile)
188 | }
189 | function humansl_profile_for_black(profile) {
190 | return humansl_profile_gen(leelaz_for_black, profile)
191 | }
192 | function humansl_profile_for_white(profile) {
193 | return humansl_profile_gen(leelaz_for_white, profile)
194 | }
195 |
196 | /////////////////////////////////////////////////
197 | // engine cache
198 |
199 | // note:
200 | // When we use Leela Zero and KataGo alternately,
201 | // leelaz.kill() pushes KataGo to cache before leelaz.start() pulls LZ from cache.
202 | // Hence we postpone calling truncate_cached_engines() until start()
203 | // so that max_cached_engines = 1 works in this situation.
204 | // (Otherwise, we need max_cached_engines = 2 wastefully.)
205 |
206 | function create_leelaz_proxy() {
207 | let lz; const proxy = {}
208 | const renew_lz = new_lz => {
209 | lz && cache_disused_engine(lz)
210 | lz = new_lz || original_create_leelaz()
211 | merge(proxy, {...lz, start, restart, kill, force_restart, instance_eq})
212 | }
213 | const start_gen = (h, command) => {
214 | const c = pull_cached_engine(h)
215 | truncate_cached_engines(); renew_lz(c); c || lz[command](h)
216 | apply_current_analysis_region(lz)
217 | }
218 | // override original methods
219 | const start = h => start_gen(h, 'start')
220 | const restart = h => start_gen({...lz.start_args(), ...(h || {})}, 'restart')
221 | const kill = () => renew_lz()
222 | // add more methods
223 | const force_restart = () => lz.restart()
224 | const instance_eq = (z) => (z === lz)
225 | renew_lz(); return proxy
226 | }
227 |
228 | let cached_engines = []
229 | function pull_cached_engine(h) {
230 | const k = cached_engines.findIndex(lz => lz.start_args_equal(h))
231 | const ret = (k >= 0) && cached_engines.splice(k, 1)[0]
232 | return ret
233 | }
234 | function cache_disused_engine(lz) {
235 | if (!lz.start_args() || !lz.is_ready()) {lz.kill(); return}
236 | pull_cached_engine(lz.start_args()) // avoid duplication
237 | lz.set_pondering(false)
238 | cached_engines.unshift(lz)
239 | }
240 | function truncate_cached_engines() {
241 | truncate(cached_engines, max_cached_engines, lz => lz.kill())
242 | }
243 |
244 | function remember(element, array, max_length, destroy) {
245 | array.unshift(element); truncate(array, max_length, destroy)
246 | }
247 | function truncate(array, max_length, destroy) {
248 | array.splice(max_length, Infinity).forEach(destroy || do_nothing)
249 | }
250 |
251 | /////////////////////////////////////////////////
252 | // restore
253 |
254 | const max_recorded_start_args = 12
255 | let recorded_start_args = [], initial_start_args
256 | function all_ready_p() {
257 | return leelaz_for_black.is_ready() &&
258 | (!leelaz_for_white || leelaz_for_white.is_ready())
259 | }
260 | function backup() {
261 | if (!all_ready_p()) {return}
262 | const args = all_start_args(), info = engine_info()
263 | const spec = z => z && [z.leelaz_command, z.leelaz_args]
264 | const s = a => JSON.stringify([spec(a.black), spec(a.white)])
265 | const s_args = s(args), different = h => s(h.args) !== s_args
266 | initial_start_args || (initial_start_args = args)
267 | recorded_start_args = recorded_start_args.filter(different)
268 | remember({args, info}, recorded_start_args, max_recorded_start_args)
269 | }
270 | function restore_initial_start_args() {restore_all_start_args(initial_start_args)}
271 | function restore(nth) {
272 | const rsa = recorded_start_args
273 | const n = nth || 0, h = rsa[n], a = h ? h.args : initial_start_args
274 | h && (rsa.splice(n, 1), rsa.unshift(h))
275 | a && restore_all_start_args(a)
276 | }
277 | function info_for_restore() {return recorded_start_args.map(h => h.info)}
278 |
279 | /////////////////////////////////////////////////
280 | // exports
281 |
282 | function engine_ids() {
283 | const engines = [leelaz_for_black, leelaz_for_white]
284 | return engines.map(lz => lz && lz.engine_id()).filter(truep)
285 | }
286 |
287 | const exported_from_leelaz = [
288 | 'send_to_leelaz',
289 | 'peek_value', 'peek_kata_raw_nn', 'peek_kata_raw_human_nn',
290 | 'get_komi', 'is_supported', 'clear_cache', 'analyze_move',
291 | ]
292 |
293 | module.exports = {
294 | // main.js only
295 | set_board, genmove, genmove_analyze, cancel_past_requests,
296 | start_leelaz, update_leelaz, kill_all_leelaz, set_pondering, all_start_args,
297 | leelaz_for_white_p, swap_leelaz_for_black_and_white, switch_leelaz,
298 | switch_to_random_leelaz, load_weight_file,
299 | unload_leelaz_for_white, leelaz_weight_file, restart,
300 | set_engine_for_white, restore, info_for_restore, backup,
301 | different_komi_for_black_and_white, startup_log,
302 | update_analysis_region, set_instant_analysis,
303 | is_moves_ownership_supported,
304 | is_sub_model_humanSL_supported,
305 | humansl_profile, humansl_profile_for_black, humansl_profile_for_white,
306 | ...aa2hash(exported_from_leelaz.map(key =>
307 | [key, (...args) => leelaz[key](...args)])),
308 | // powered_goban.js only
309 | set_handlers, engine_ids,
310 | // both
311 | katago_p, support_endstate_p: katago_p, engine_info, is_gorule_supported,
312 | // others
313 | current_preset_label,
314 | }
315 |
--------------------------------------------------------------------------------
/src/amb_gain.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function get_amb_gain(game, recent) {
4 | const ambiguity_gain = get_amb_gain_sub(h => h.stone_entropy, ['stone_entropy'], game, recent)
5 | const moyolead_gain = get_moyolead_gain(game, recent)
6 | return {ambiguity_gain, moyolead_gain}
7 | }
8 |
9 | function get_amb_gain_sub(f, keys, game, recent_same_player_moves) {
10 | const recent = recent_same_player_moves * 2
11 | // "recent" should be an even number so that the opponent's values
12 | // are kept unchanged.
13 | const weight_for = distance => 1 + Math.cos(Math.PI * distance / recent)
14 | const {move_count} = game
15 | const from = Math.max(game.init_len, move_count - recent)
16 | const rev = seq_from_to(from, move_count).toReversed().map(game.ref)
17 | .map(h => pick_keys(h, ...keys, 'move_count', 'is_black'))
18 | rev.map(f).forEach((z, k, a) => rev[k].gain = z - a[k + 1])
19 | return aa2hash([true, false].map(is_black => {
20 | const color_p = h => !xor(h.is_black, is_black)
21 | const hs = rev.filter(color_p).filter(h => truep(h.gain))
22 | const weights = hs.map(h => weight_for(hs[0].move_count - h.move_count))
23 | const average_gain = weighted_average(hs.map(h => h.gain), weights)
24 | return [is_black, average_gain]
25 | }))
26 | }
27 |
28 | function get_moyolead_gain(game, recent) {
29 | const bmg = get_black_moyolead_gain(game, recent)
30 | const b = bmg[true], w = bmg[false]
31 | return {true: b, false: - w}
32 | }
33 |
34 | function get_black_moyolead_gain(game, recent) {
35 | const keys = [
36 | 'black_settled_territory', 'white_settled_territory', 'score_without_komi'
37 | ]
38 | const f = h => (h.score_without_komi - game.komi)
39 | - (h.black_settled_territory - h.white_settled_territory)
40 | return get_amb_gain_sub(f, keys, game, recent)
41 | }
42 |
43 | /////////////////////////////////////////////////
44 | // exports
45 |
46 | module.exports = {
47 | get_amb_gain,
48 | }
49 |
--------------------------------------------------------------------------------
/src/area.js:
--------------------------------------------------------------------------------
1 | // clustering & counting of areas
2 |
3 | // fix me: inefficient...
4 |
5 | const minor_ownership = 0.1
6 | const too_large_cluster_size = 40
7 | const narrow_corridor_radius = 3
8 | const too_small_corridor_cluster_size = 10
9 | const too_small_core_cluster_size = 15
10 |
11 | const category_spec = [
12 | {color: 'black', type: 'major', ownership_range: [minor_ownership, Infinity]},
13 | {color: 'white', type: 'major', ownership_range: [- Infinity, - minor_ownership]},
14 | {color: 'black', type: 'minor', ownership_range: [0, minor_ownership]},
15 | {color: 'white', type: 'minor', ownership_range: [- minor_ownership, 0]},
16 | ]
17 |
18 | //////////////////////////////////////
19 | // main
20 |
21 | function endstate_clusters_for(endstate, stones) {
22 | if (!size_eq(endstate, stones)) {return []}
23 | initialize()
24 | const grid_for = z => ({ownership: z, id: null})
25 | const grid = aa_map(endstate, grid_for)
26 | const get_clusters = (_, cat) => clusters_in_category(cat, grid, stones)
27 | return category_spec.flatMap(get_clusters)
28 | }
29 |
30 | function clusters_in_category(category, grid, stones) {
31 | const {type} = category_spec[category]
32 | const region = region_for_category(category, grid)
33 | const clusters = clusters_in_region(region, grid, category)
34 | const divide_maybe = c => divide_large_cluster(c, grid, category)
35 | const ret = (type === 'minor') ? clusters : clusters.flatMap(divide_maybe)
36 | return ret.map(c => finalize_cluster(c, grid, stones))
37 | }
38 |
39 | function region_for_category(category, grid) {
40 | const region = [], ok = g => !truep(g.id) && in_category(category, g.ownership)
41 | aa_each(grid, (g, i, j) => ok(g) && region.push([i, j]))
42 | return region
43 | }
44 |
45 | function in_category(category, ownership) {
46 | const {ownership_range: [a, b]} = category_spec[category]
47 | return a <= ownership && ownership < b
48 | }
49 |
50 | function divide_large_cluster(cluster, grid, category) {
51 | if (cluster.ijs.length < too_large_cluster_size) {return [cluster]}
52 | const region = cluster.ijs
53 | cancel_cluster(cluster, grid)
54 | const core = core_in_region(region, narrow_corridor_radius, in_board_checker(grid))
55 | // determine corridor_clusters first because
56 | // we will cancel too small clusters there
57 | // and let them be parts of core_clusters.
58 | const corridor_clusters =
59 | corridor_clusters_in(region, core, grid, category, narrow_corridor_radius)
60 | const core_clusters = core_clusters_in(region, core, grid, category)
61 | const rest_clusters = clusters_in_region(region, grid, category)
62 | return [...core_clusters, ...corridor_clusters, ...rest_clusters]
63 | }
64 |
65 | function in_board_checker(grid) {return ([i, j]) => !!aa_ref(grid, i, j)}
66 |
67 | let last_category_id = 0
68 | function initialize() {last_category_id = 0}
69 | function new_cluster_id() {return ++last_category_id}
70 |
71 | function size_eq(aa1, aa2) {
72 | const len = aa => JSON.stringify(aa.map(a => a.length))
73 | return len(aa1) === len(aa2)
74 | }
75 |
76 | //////////////////////////////////////
77 | // clustering
78 |
79 | function clusters_in_region(region, grid, category) {
80 | return region.map(ij => cluster_from(ij, region, grid, category)).filter(truep)
81 | }
82 |
83 | function cluster_from([i, j], region, grid, category) {
84 | if (truep(grid[i][j].id)) {return null}
85 | const {color, type} = category_spec[category], id = new_cluster_id()
86 | const state = {ijs: [], newcomers: [], region, id}
87 | add_newcomer_maybe([i, j], state, grid)
88 | while (!empty(state.newcomers)) {search_around(state.newcomers.pop(), state, grid)}
89 | const {ijs} = state
90 | return make_cluster(id, color, type, ijs)
91 | }
92 |
93 | function add_newcomer_maybe(ij, state, grid) {
94 | const g = aa_ref(grid, ...ij)
95 | if (!g || truep(g.id) || !is_member(ij, state.region)) {return}
96 | state.ijs.push(ij); state.newcomers.push(ij); g.id = state.id
97 | }
98 |
99 | function search_around(ij, state, grid) {
100 | around_idx(ij).forEach(idx => add_newcomer_maybe(idx, state, grid))
101 | }
102 |
103 | function make_cluster(id, color, type, ijs) {
104 | return {id, color, type, ijs}
105 | }
106 |
107 | function finalize_cluster(cluster, grid, stones) {
108 | const {id, ijs, color} = cluster, c = {...cluster}; delete c.ijs // for efficiency
109 | return {...c, ...cluster_characteristics(id, ijs, grid, color, stones)}
110 | }
111 |
112 | function cluster_characteristics(id, ijs, grid, color, stones) {
113 | const sum = (v, w) => v.map((_, k) => v[k] + w[k]), zero = [0, 0, 0, 0]
114 | const stone_sign = (i, j) => {
115 | const s = stones[i][j]; return !s.stone ? 0 : s.black ? 1 : -1
116 | }
117 | const interior = (i, j, sign) => {
118 | const outside_grid = {ownership: 0}
119 | const ownership_at = idx => (aa_ref(grid, ...idx) || outside_grid).ownership
120 | const opposite_color_p = idx => (ownership_at(idx) * sign < 0)
121 | return !around_idx([i, j]).find(opposite_color_p)
122 | }
123 | const f = ([i, j]) => {
124 | const ow = grid[i][j].ownership, sign = color === 'black' ? 1 : -1
125 | const my_stone_p = stones && (stone_sign(i, j) === sign)
126 | const territory_p = !my_stone_p && interior(i, j, sign)
127 | const te = territory_p ? ow : 0
128 | return [ow, te, i * ow, j * ow]
129 | }
130 | const [ownership_sum, territory_sum, i_sum, j_sum] = ijs.map(f).reduce(sum, zero)
131 | const center_idx = [i_sum, j_sum].map(z => z / ownership_sum)
132 | const boundary = boundary_of(id, ijs, grid)
133 | return {ownership_sum, territory_sum, center_idx, boundary}
134 | }
135 |
136 | function boundary_of(id, ijs, grid) {
137 | const same_cluster_p = idx => (aa_ref(grid, ...idx) || {}).id === id
138 | const checker_for = ij =>
139 | (idx, direction) => same_cluster_p(idx) ? null : [ij, direction]
140 | const boundary_around = ij => around_idx(ij).map(checker_for(ij)).filter(truep)
141 | return ijs.flatMap(boundary_around)
142 | }
143 |
144 | function cancel_cluster(cluster, grid) {
145 | cluster.ijs.forEach(([i, j]) => (grid[i][j].id = null))
146 | }
147 |
148 | //////////////////////////////////////
149 | // separate corridors for "natural" clustering
150 |
151 | function core_in_region(region, radius, in_board) {
152 | let core = region
153 | do_ntimes(radius, () => (core = erosion(core, in_board)))
154 | return core
155 | }
156 |
157 | function corridor_clusters_in(region, core, grid, category, corridor_radius) {
158 | if (corridor_radius <=0) {return []}
159 | const corridors = corridors_in(region, core, grid, corridor_radius)
160 | const clusters = clusters_in_region(corridors, grid, category)
161 | return cancel_small_clusters(clusters, grid, too_small_corridor_cluster_size)
162 | }
163 |
164 | function cancel_small_clusters(clusters, grid, small_cluster_size) {
165 | const acceptable = c => c.ijs.length > small_cluster_size
166 | const inacceptable = c => !acceptable(c)
167 | const acceptable_clusters = clusters.filter(acceptable)
168 | const inacceptable_clusters = clusters.filter(inacceptable)
169 | const cancel = c => cancel_cluster(c, grid)
170 | inacceptable_clusters.forEach(cancel)
171 | return acceptable_clusters
172 | }
173 |
174 | function corridors_in(region, core, grid, corridor_radius) {
175 | let dilated_core = core
176 | const dilate = () => (dilated_core = dilation(dilated_core, region))
177 | // "* 2" for recovering corners of rectangles
178 | do_ntimes(corridor_radius * 2, dilate)
179 | return region.filter(ij => !is_member(ij, dilated_core))
180 | }
181 |
182 | function erosion(region, in_board) {
183 | const is_not_member = idx => in_board(idx) && !is_member(idx, region)
184 | const is_inside = ij => !find_around(ij, is_not_member)
185 | return region.filter(is_inside)
186 | }
187 |
188 | function dilation(region, limit_region) {
189 | const is_in_region = idx => is_member(idx, region)
190 | const is_in_closure = ij => is_in_region(ij) || find_around(ij, is_in_region)
191 | return limit_region.filter(is_in_closure)
192 | }
193 |
194 | function find_around(idx, pred) {return around_idx(idx).find(pred)}
195 |
196 | function is_member(idx, region) {
197 | const eq = ([i1, j1], [i2, j2]) => (i1 === i2 && j1 === j2)
198 | return region.find(ij => eq(idx, ij))
199 | }
200 |
201 | //////////////////////////////////////
202 | // divide large areas by bottlenecks
203 |
204 | function core_clusters_in(region, core, grid, category) {
205 | const rest = region.filter(ij => !truep(aa_ref(grid, ...ij).id))
206 | const core_clusters = clusters_in_region(core, grid, category)
207 | inflate_clusters(core_clusters, rest, grid)
208 | const survived_clusters =
209 | cancel_small_clusters(core_clusters, grid, too_small_core_cluster_size)
210 | inflate_clusters(survived_clusters, rest, grid)
211 | return survived_clusters
212 | }
213 |
214 | function inflate_clusters(clusters, region, grid) {
215 | const cluster_for = aa2hash(clusters.map(c => [c.id, c]))
216 | const id_at = ij => (aa_ref(grid, ...ij) || {}).id
217 | const fresh = ij => !truep(id_at(ij))
218 | const labeled = ij => !fresh(ij) && is_member(ij, region)
219 | const penetrate = r => {
220 | // pool is needed for breadth-first search
221 | const ret = r.filter(fresh), pool = []
222 | const pool_maybe = (ij, touched) => touched && pool.push([ij, id_at(touched)])
223 | const check = ij => pool_maybe(ij, find_around(ij, labeled))
224 | ret.forEach(check)
225 | if (empty(pool)) {return []}
226 | pool.forEach(([ij, id]) => {
227 | aa_ref(grid, ...ij).id = id; cluster_for[id].ijs.push(ij)
228 | })
229 | return ret
230 | }
231 | let rest = region
232 | while (!empty(rest)) {rest = penetrate(rest)}
233 | }
234 |
235 | //////////////////////////////////////
236 | // exports
237 |
238 | module.exports = {
239 | endstate_clusters_for,
240 | }
241 |
--------------------------------------------------------------------------------
/src/branch.js:
--------------------------------------------------------------------------------
1 | // private
2 |
3 | let branch_structure
4 | function clear_branch() {branch_structure = []}
5 | clear_branch()
6 |
7 | function get_branch_at(move_count) {
8 | const a = branch_structure[move_count]
9 | return a || (branch_structure[move_count] = [])
10 | }
11 | function add_branch(move_count, another_game) {
12 | const a = get_branch_at(move_count), ref = gm => gm.ref(move_count + 1)
13 | !a.map(ref).includes(ref(another_game)) && a.push(another_game)
14 | }
15 |
16 | // public
17 |
18 | function branch_at(move_count) {return branch_structure[move_count]}
19 | function update_branch_for(game, all_games) {
20 | const hist = game.array_until(Infinity), {init_len, brothers} = game
21 | const add = gm => {
22 | const c = gm.strictly_common_header_length(hist)
23 | const branch_p = (c > init_len) || brothers.includes(gm)
24 | branch_p && add_branch(c, gm)
25 | }
26 | clear_branch(); all_games.forEach(gm => (gm === game) || add(gm))
27 | }
28 |
29 | ///////////////////////////////////////
30 | // exports
31 |
32 | module.exports = {
33 | branch_at,
34 | update_branch_for,
35 | }
36 |
--------------------------------------------------------------------------------
/src/contributors.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | LizGoban Contributors
7 |
8 |
9 |
10 |
11 |
12 |
13 | LizGoban Contributors
14 |
15 | @kaorahi
16 | @ivysrono
17 | @zakki (Kensuke Matsuzaki)
18 | @hebaeba
19 | @qcgm1978 (Youth)
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/coord.js:
--------------------------------------------------------------------------------
1 | // coordinates converter
2 |
3 | // idx [i][j] of array: [0][0] = top left, [0][18] = top right
4 | // coord (x, y) on canvas: (0, 0) = top left, (width, 0) = top right
5 | // move: "A19" = top left, "T19" = top right
6 | // sgfpos: "aa" = top left, "sa" = top right
7 | // serial k = 0, ..., 360: 0 = top left, 18 = top right, 360 = bottom right
8 |
9 | const pass_command = 'pass'
10 |
11 | /////////////////////////////////////////////////
12 | // board_size
13 |
14 | // Caution: need to call set_board_size in *each* process
15 |
16 | let the_board_size = 19
17 | function board_size() {return the_board_size}
18 | function set_board_size(n) {the_board_size = n}
19 |
20 | function with_board_size(bsize, proc, ...args) {
21 | const previous = board_size(); set_board_size(bsize)
22 | const ret = proc(...args); set_board_size(previous); return ret
23 | }
24 |
25 | /////////////////////////////////////////////////
26 | // serial (<=> idx) <=> move
27 |
28 | function valid_serial_p(k) {return 0 <= k && k < board_size()**2}
29 |
30 | function serial2idx(k) {
31 | if (!valid_serial_p(k)) {return idx_pass}
32 | const bsize = board_size(); return [Math.floor(k / bsize), k % bsize]
33 | }
34 |
35 | function serial2move(k) {return idx2move(...serial2idx(k)) || pass_command}
36 |
37 | function idx2serial(i, j) {return i * board_size() + j}
38 |
39 | function move2serial(move) {return idx2serial(...move2idx(move))}
40 |
41 | /////////////////////////////////////////////////
42 | // idx <=> move
43 |
44 | const col_name = 'ABCDEFGHJKLMNOPQRST'
45 | const idx_pass = [-1, -1]
46 | const stars = {
47 | 19: [[3, 3], [3, 9], [3, 15], [9, 3], [9, 9], [9, 15], [15, 3], [15, 9], [15, 15]],
48 | 13: [[3, 3], [3, 9], [9, 3], [9, 9], [6, 6]],
49 | 9: [[4,4]],
50 | }
51 |
52 | function idx2rowcol(i, j) {
53 | const bsize = board_size()
54 | return (0 <= i) && (i < bsize) && (0 <= j) && (j < bsize) ?
55 | [to_s(bsize - i), col_name[j]] : [null, null]
56 | }
57 |
58 | function idx2move(i, j) {
59 | const [row, col] = idx2rowcol(i, j); return truep(row) && (col + row)
60 | }
61 |
62 | function move2idx(move) {return move2idx_maybe(move) || idx_pass}
63 | function move2idx_maybe(move) {
64 | const m = move.match(/([A-HJ-T])((1[0-9])|[1-9])/), [dummy, col, row] = m || []
65 | return m && [board_size() - to_i(row), col_name.indexOf(col)]
66 | }
67 |
68 | /////////////////////////////////////////////////
69 | // idx <=> coord
70 |
71 | function translator_pair([from1, from2], [to1, to2]) {
72 | // [from1, from2] * scale + [shift, shift] = [to1, to2]
73 | const d = from2 - from1, scale = (to2 - to1) / d, shift = (from2 * to1 - from1 * to2) / d
74 | const trans = (x => x * scale + shift), inv = (z => (z - shift) / scale)
75 | return [trans, inv]
76 | }
77 | function clipped_translator(from, to) {
78 | const [t, _] = translator_pair(from, to)
79 | return val => clip(t(val), ...to)
80 | }
81 |
82 | function idx2coord_translator_pair(canvas, xmargin, ymargin, is_square) {
83 | // u = j, v = i
84 | const [uv2xy, xy2uv] =
85 | uv2coord_translator_pair(canvas, [0, board_size() - 1], [0, board_size() - 1],
86 | xmargin, ymargin, is_square)
87 | return [((i, j) => uv2xy(j, i)), ((x, y) => xy2uv(x, y).reverse())]
88 | }
89 |
90 | function uv2coord_translator_pair(canvas, u_min_max, v_min_max, xmargin, ymargin,
91 | is_square) {
92 | // u: horizontal, v: vertical
93 | let w = canvas.width, h = canvas.height
94 | is_square && (w = h = Math.min(w, h))
95 | const [xtrans, xinv] = translator_pair(u_min_max, [xmargin, w - xmargin])
96 | const [ytrans, yinv] = translator_pair(v_min_max, [ymargin, h - ymargin])
97 | const to = (u, v) => [xtrans(u), ytrans(v)]
98 | const from = (x, y) => [Math.round(xinv(x)), Math.round(yinv(y))]
99 | return [to, from]
100 | }
101 |
102 | /////////////////////////////////////////////////
103 | // sgfpos (<=> idx) <=> move
104 |
105 | // https://www.red-bean.com/sgf/go.html
106 | // A pass move is shown as '[]' or alternatively as '[tt]' (only for boards <= 19x19)
107 |
108 | const sgfpos_name = "abcdefghijklmnopqrs"
109 | const sgfpos_pass = "tt", sgfpos_pass_FF4 = ""
110 |
111 | function idx2sgfpos(i, j) {
112 | return sgfpos_name[j] + sgfpos_name[i]
113 | }
114 |
115 | function sgfpos2idx(pos) {
116 | if (pos === sgfpos_pass || pos === sgfpos_pass_FF4) {return idx_pass}
117 | const [j, i] = pos.split('').map(c => sgfpos_name.indexOf(c))
118 | return [i, j]
119 | }
120 |
121 | function move2sgfpos(move) {
122 | // pass = 'tt'
123 | const [i, j] = move2idx(move)
124 | return i >= 0 ? idx2sgfpos(i, j) : sgfpos_pass
125 | }
126 |
127 | function sgfpos2move(pos) {
128 | return idx2move(...sgfpos2idx(pos))
129 | }
130 |
131 | module.exports = {
132 | pass_command,
133 | idx2rowcol, move2idx_maybe,
134 | idx2move, move2idx, idx2coord_translator_pair, uv2coord_translator_pair,
135 | serial2idx, serial2move, idx2serial, move2serial,
136 | translator_pair, clipped_translator,
137 | board_size, set_board_size, with_board_size, sgfpos2move, move2sgfpos, stars,
138 | }
139 |
--------------------------------------------------------------------------------
/src/copy_stones.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | ///////////////////////////////////////////////
4 | // copy
5 |
6 | function copy_stones(stones, analysis_region) {
7 | const aa = from_stones(slice_stones(stones, analysis_region))
8 | return to_text(aa)
9 | }
10 |
11 | function slice_stones(stones, analysis_region) {
12 | const bsize = board_size(), m = bsize - 1
13 | const [[i1, i2], [j1, j2]] = analysis_region || [[0, m], [0, m]]
14 | return stones.slice(i1, i2 + 1).map(row => row.slice(j1, j2 + 1))
15 | }
16 |
17 | function from_stones(stones) {
18 | const to_ch = h => !h.stone ? '.' : h.black ? 'X' : 'O'
19 | return stones.map(row => row.map(to_ch))
20 | }
21 |
22 | function to_text(aa) {return aa.map(a => a.join('')).join('\n')}
23 |
24 | ///////////////////////////////////////////////
25 | // paste
26 |
27 | function paste_stones(stones, analysis_region, text, bturn, [i_sign, j_sign]) {
28 | const bsize = board_size(), m = bsize - 1
29 | const pat = from_text(text), pat_h = pat.length, pat_w = pat[0].length
30 | const [[i1, i2], [j1, j2]] = analysis_region || [[0, m], [0, m]]
31 | const base = (k1, k2, k_sign, len) => k_sign > 0 ? k1 : k2 - len + 1
32 | const top = base(i1, i2, i_sign, pat_h), left = base(j1, j2, j_sign, pat_w)
33 | return paste_stones_sub(stones, analysis_region, pat, bturn, top, left)
34 | }
35 |
36 | function paste_stones_sub(stones, analysis_region, pat, bturn, i1, j1) {
37 | const aa = paste_to(from_stones(stones), pat, i1, j1)
38 | return to_sgf(aa, bturn)
39 | }
40 |
41 | function from_text(text) {
42 | return text.split('\n').filter(identity).map(s => s.split(''))
43 | }
44 |
45 | function paste_to(aa, pat, i1, j1) {
46 | // side effect: aa and pat are modified
47 | pat.splice(aa.length - i1)
48 | seq(pat.length).forEach(di => {
49 | const row = aa[i1 + di]
50 | const p = pat[di]; p.splice(row.length - j1)
51 | row.splice(j1, p.length, ...p)
52 | })
53 | return aa
54 | }
55 |
56 | function to_sgf(aa, bturn) {
57 | const a = aa_map(aa, (c, i, j) => [c, i, j]).flat()
58 | const is_stone = xo => ([c, i, j]) => c === xo
59 | const sgfpos = ([c, i, j]) => `[${move2sgfpos(idx2move(i, j))}]`
60 | const maybe = (prop, val) => val ? prop + val : ''
61 | const prop = ([ident, xo]) =>
62 | maybe(ident, a.filter(is_stone(xo)).map(sgfpos).join(''))
63 | const [ab, aw] = [['AB', 'X'], ['AW', 'O']].map(prop)
64 | return `(;SZ[${aa.length}]PL[${bturn ? 'B' : 'W'}]${ab}${aw})`
65 | }
66 |
67 | ///////////////////////////////////////////////
68 | // exports
69 |
70 | module.exports = {
71 | copy_stones,
72 | paste_stones,
73 | }
74 |
--------------------------------------------------------------------------------
/src/draw.js:
--------------------------------------------------------------------------------
1 | // -*- coding: utf-8 -*-
2 |
3 | /////////////////////////////////////////////////
4 | // setup
5 |
6 | const {globalize} = require('./globalize.js')
7 | globalize({
8 | clip_init_len, latest_move, latest_move_and_nearest_future_move, b_winrate,
9 | origin_b_winrate, origin_score, fake_winrate, fake_winrate_for, score_bar_p,
10 | mc2movenum, alternative_engine_for_white_p, zone_color,
11 | winrate_history_values_of,
12 | winrate_bar_suggest_prop, winrate_bar_order_set_style, suggest_color,
13 | })
14 |
15 | /////////////////////////////////////////////////
16 | // draw_*
17 |
18 | const {
19 | draw_raw_goban, draw_main_goban,
20 | draw_goban_with_principal_variation,
21 | draw_goban_with_expected_variation,
22 | draw_goban_with_future_moves,
23 | draw_goban_with_subboard_stones_suggest,
24 | draw_goban_with_original_pv,
25 | draw_endstate_goban, draw_thumbnail_goban, draw_zone_color_chart,
26 | target_move, set_target_move,
27 | } = require('./draw_goban.js')
28 |
29 | const {
30 | draw_winrate_bar_sub, update_winrate_trail, score_bar_fitter,
31 | get_pv_trail_for, get_winrate_trail,
32 | } = require('./draw_winrate_bar.js')
33 | function draw_winrate_bar(...args) {draw_winrate_bar_sub(target_move(), ...args)}
34 |
35 | const {draw_winrate_graph} = require('./draw_winrate_graph.js')
36 |
37 | const {draw_visits_trail_sub} = require('./draw_visits_trail.js')
38 | function draw_visits_trail(...args) {draw_visits_trail_sub(get_winrate_trail(), ...args)}
39 |
40 | const {
41 | draw_endstate_distribution, hide_endstate_distribution,
42 | } = require('./draw_endstate_dist.js')
43 |
44 | /////////////////////////////////////////////////
45 | // for mapping from goban to winrate bar
46 |
47 | // convert score to "winrate of corresponding position on winrate bar"
48 | // to cheat drawing functions in score_bar mode
49 | function fake_winrate(suggest, bturn) {
50 | return fake_winrate_for(suggest.winrate, suggest.score_without_komi, bturn)
51 | }
52 | function fake_winrate_for(winrate, score_without_komi, bturn) {
53 | if (!score_bar_p()) {return winrate}
54 | score_bar_fitter.update_center(R.score_without_komi - R.komi)
55 | const {lower, upper} = score_bar_fitter.range()
56 | const score = score_without_komi - R.komi
57 | const fake_b_wr = 100 * (score - lower) / (upper - lower)
58 | return clip(flip_maybe(fake_b_wr, bturn), 0, 100)
59 | }
60 |
61 | function score_bar_p() {return R.score_bar && R.is_katago}
62 |
63 | /////////////////////////////////////////////////
64 | // winrate bar style
65 |
66 | function winrate_bar_suggest_prop(s, move_count) {
67 | // const
68 | const next_color = '#48f'
69 | const next_vline_color = 'rgba(64,128,255,0.5)'
70 | const target_vline_color = 'rgba(255,64,64,0.5)'
71 | const normal_aura_color = 'rgba(235,148,0,0.8)'
72 | const target_aura_color = 'rgba(0,192,0,0.8)'
73 | // main
74 | const {move} = s, winrate = fake_winrate(s)
75 | const target = target_move()
76 | const edge_color = target ? 'rgba(128,128,128,0.5)' : '#888'
77 | const target_p = (move === target), next_p = is_next_move(move, move_count)
78 | const alpha = target_p ? 1.0 : target ? 0.3 : 0.8
79 | const {fill} = suggest_color(s, alpha)
80 | const fan_color = (!target && next_p) ? next_color : fill
81 | const vline_color = target_p ? target_vline_color :
82 | next_p ? next_vline_color : null
83 | const aura_color = target_p ? target_aura_color : normal_aura_color
84 | const major = s.visits >= R.max_visits * 0.3 || s.prior >= 0.3 ||
85 | s.order < 3 || s.winrate_order < 3 || target_p || next_p
86 | const eliminated = target && !target_p
87 | const draw_order_p = major && !eliminated
88 | return {edge_color, fan_color, vline_color, aura_color, alpha,
89 | target_p, draw_order_p, next_p, winrate}
90 | }
91 |
92 | function suggest_color(suggest, alpha) {
93 | const hue = winrate_color_hue(suggest.winrate, suggest.score_without_komi)
94 | const alpha_emphasis = emph => {
95 | const max_alpha = 0.5, visits_ratio = clip(suggest.visits / (R.visits + 1), 0, 1)
96 | return max_alpha * visits_ratio ** (1 - emph)
97 | }
98 | const hsl_e = (h, s, l, emph) => hsla(h, s, l, alpha || alpha_emphasis(emph))
99 | const stroke = hsl_e(hue, 100, 20, 0.85), fill = hsl_e(hue, 100, 50, 0.4)
100 | return {stroke, fill}
101 | }
102 |
103 | function winrate_color_hue(winrate, score) {
104 | const cyan_hue = 180, green_hue = 120, yellow_hue = 60, red_hue = 0
105 | const unit_delta_hue = green_hue - yellow_hue
106 | const unit_delta_winrate = 5, unit_delta_score = 5
107 | // winrate gain
108 | const wr0 = flip_maybe(origin_b_winrate())
109 | const delta_by_winrate = (winrate - wr0) / unit_delta_winrate
110 | // score gain
111 | const s0 = origin_score()
112 | const delta_by_score_maybe = truep(score) && truep(s0) &&
113 | (score - s0) * (R.bturn ? 1 : -1) / unit_delta_score
114 | const delta_by_score = delta_by_score_maybe || delta_by_winrate
115 | // color for gain
116 | const delta_hue = (delta_by_winrate + delta_by_score) / 2 * unit_delta_hue
117 | return to_i(clip(yellow_hue + delta_hue, red_hue, cyan_hue))
118 | }
119 |
120 | function origin_b_winrate() {return origin_gen(b_winrate)}
121 | function origin_score() {
122 | const prev_score = nth_prev => winrate_history_ref('score_without_komi', nth_prev)
123 | return origin_gen(prev_score)
124 | }
125 | function origin_gen(get_prev) {return [1, 2, 0].map(get_prev).find(truep)}
126 |
127 | function winrate_bar_order_set_style(s, fontsize, g) {
128 | const firstp = (s.order === 0)
129 | g.fillStyle = firstp ? WINRATE_BAR_FIRST_ORDER_COLOR : WINRATE_BAR_ORDER_COLOR
130 | return fontsize * (firstp ? 1.5 : 1)
131 | }
132 |
133 | /////////////////////////////////////////////////
134 | // zone color
135 |
136 | function zone_color(i, j, alpha) {
137 | if (i < 0 || j < 0) {return TRANSPARENT}
138 | const mid = (board_size() - 1) / 2
139 | // right = 0/4, top = 1/4, left = 2/4, bottom = 3/4, right = 4/4
140 | const direction = (Math.atan2(i - mid, mid - j) / Math.PI + 1) * 0.5
141 | const height = 1 - Math.max(...[i, j].map(k => Math.abs(k - mid))) / mid
142 | const h = zone_hue(direction), l = 50 + 50 * height
143 | return hsla(h, 70, l, alpha)
144 | }
145 |
146 | const zone_hue_knot = [
147 | // These colors looks approximately equidistant for my eyes.
148 | 0, // red
149 | 15,
150 | 30, // orange
151 | 45,
152 | 60, // yellow
153 | 70, // (very near to yellow)
154 | 120, // green
155 | 160, // (near to cyan)
156 | 180, // cyan
157 | 200, // (near to cyan)
158 | 240, // blue
159 | 270, // (near to purple)
160 | 280, // purple
161 | 290, // (near to purple)
162 | 320, // pink
163 | 340,
164 | 360, // (red)
165 | ]
166 |
167 | function zone_hue(direction) {
168 | // 0 <= direction <= 1
169 | const epsilon = 1e-8, d = clip((direction + 3/8) % 1, 0, 1 - epsilon)
170 | // piecewise linear interpolation
171 | const n = zone_hue_knot.length - 1
172 | const k = Math.floor(d * n), s = d * n - k
173 | return (1 - s) * zone_hue_knot[k] + s * zone_hue_knot[k + 1]
174 | }
175 |
176 | /////////////////////////////////////////////////
177 | // utils
178 |
179 | // stones
180 |
181 | function is_next_move(move, move_count) {
182 | const [i, j] = move2idx(move); if (i < 0) {return false}
183 | const s = aa_ref(R.stones, i, j) || {}, as = s.anytime_stones || []
184 | const mc_p = truep(move_count) && move_count !== R.move_count
185 | return mc_p ? !!as.find(z => z.move_count === move_count + 1) : s.next_move
186 | }
187 |
188 | function latest_move(moves, show_until) {
189 | return latest_move_and_nearest_future_move(moves, show_until)[0]
190 | }
191 | function latest_move_and_nearest_future_move(moves, show_until) {
192 | if (!moves) {return []}
193 | const n = moves.findIndex(z => (z.move_count > show_until))
194 | const nearest_future = moves[n], latest = (n >= 0) ? moves[n - 1] : last(moves)
195 | return [latest, nearest_future]
196 | }
197 |
198 | // handicaps
199 |
200 | function clip_init_len(move_count) {return clip(move_count, R.init_len)}
201 | function mc2movenum(move_count) {return clip(move_count - R.init_len, 0)}
202 | function max_movenum() {return mc2movenum(R.history_length)}
203 |
204 | // visits & winrate
205 |
206 | function b_winrate(nth_prev) {return winrate_history_ref('r', nth_prev)}
207 | function winrate_history_ref(key, nth_prev) {
208 | const mc = finite_or(move_count_for_suggestion(), R.move_count)
209 | const [whs, rest] = R.winrate_history_set
210 | const winrate_history = !truep(nth_prev) ? R.winrate_history :
211 | (alternative_engine_for_white_p() && !R.bturn) ? whs[1] : whs[0]
212 | return (winrate_history[mc - (nth_prev || 0)] || {})[key]
213 | }
214 |
215 | function winrate_history_values_of(key) {return R.winrate_history.map(h => h[key])}
216 |
217 | function alternative_engine_for_white_p() {
218 | const a = R.winrate_history_set; return a && (a[0].length > 1)
219 | }
220 |
221 | //////////////////////////////////
222 |
223 | module.exports = {
224 | movenum: () => mc2movenum(R.move_count), max_movenum, clip_init_len,
225 | draw_thumbnail_goban,
226 | draw_raw_goban, draw_main_goban,
227 | draw_goban_with_principal_variation,
228 | draw_goban_with_expected_variation,
229 | draw_goban_with_future_moves,
230 | draw_goban_with_subboard_stones_suggest,
231 | draw_goban_with_original_pv,
232 | draw_endstate_goban,
233 | draw_winrate_graph, draw_winrate_bar, draw_visits_trail, draw_zone_color_chart,
234 | draw_endstate_distribution, hide_endstate_distribution,
235 | get_pv_trail_for,
236 | update_winrate_trail, clear_canvas, is_next_move, latest_move,
237 | target_move, set_target_move,
238 | }
239 |
--------------------------------------------------------------------------------
/src/draw_common.js:
--------------------------------------------------------------------------------
1 | ////////////////////////////
2 | // color
3 |
4 | const BLACK = "#000", WHITE = "#fff"
5 | const GRAY = "#ccc", DARK_GRAY = "#444"
6 | const RED = "#f00", GREEN = "#0c0", BLUE = "#88f", YELLOW = "#ff0"
7 | const ORANGE = "#fc8d49"
8 | const DARK_YELLOW = "#c9a700", TRANSPARENT = "rgba(0,0,0,0)"
9 | const MAYBE_BLACK = "rgba(0,0,0,0.5)", MAYBE_WHITE = "rgba(255,255,255,0.5)"
10 | const VAGUE_BLACK = 'rgba(0,0,0,0.3)', VAGUE_WHITE = 'rgba(255,255,255,0.3)'
11 | const PALE_BLUE = "rgba(128,128,255,0.5)"
12 | const PALE_BLACK = "rgba(0,0,0,0.1)", PALE_WHITE = "rgba(255,255,255,0.3)"
13 | const PALER_BLACK = "rgba(0,0,0,0.07)", PALER_WHITE = "rgba(255,255,255,0.21)"
14 | const PALE_RED = "rgba(255,0,0,0.1)", PALE_GREEN = "rgba(0,255,0,0.1)"
15 | const WINRATE_TRAIL_COLOR = 'rgba(160,160,160,0.8)'
16 | const WINRATE_BAR_ORDER_COLOR = '#d00', WINRATE_BAR_FIRST_ORDER_COLOR = '#0a0'
17 | const EXPECTED_COLOR = 'rgba(0,0,255,0.3)', UNEXPECTED_COLOR = 'rgba(255,0,0,0.8)'
18 | // p: pausing, t: trial, r: ref
19 | const GOBAN_BG_COLOR = {
20 | "": "#f9ca91", p: "#a38360", t: "#a38360", pt: "#a09588", r: "#a09588",
21 | }
22 |
23 | ////////////////////////////
24 | // graphics
25 |
26 | function clear_canvas(canvas, bg_color, g) {
27 | canvas.style.background = bg_color || TRANSPARENT;
28 | (g || canvas.getContext("2d")).clearRect(0, 0, canvas.width, canvas.height)
29 | }
30 |
31 | function drawers_trio(gen) {
32 | const draw = (...a) => {const g = last(a); g.beginPath(); gen(...a); return g}
33 | const edged = (...a) => draw(...a).stroke()
34 | const filled = (...a) => draw(...a).fill()
35 | const both = (...a) => {filled(...a); edged(...a)}
36 | return [edged, filled, both]
37 | }
38 |
39 | function line_gen(...args) {
40 | // usage: line([x0, y0], [x1, y1], ..., [xn, yn], g)
41 | if (args.length < 3) {return}
42 | const g = args.pop(), [[x0, y0], ...xys] = args
43 | g.moveTo(x0, y0); xys.forEach(xy => g.lineTo(...xy))
44 | }
45 | function rect_gen([x0, y0], [x1, y1], g) {g.rect(x0, y0, x1 - x0, y1 - y0)}
46 | function circle_gen([x, y], r, g) {g.arc(x, y, r, 0, 2 * Math.PI)}
47 | function fan_gen([x, y], r, [deg1, deg2], g) {
48 | g.moveTo(x, y)
49 | g.arc(x, y, r, deg1 * Math.PI / 180, deg2 * Math.PI / 180); g.closePath()
50 | }
51 | function square_around_gen([x, y], radius, g) {
52 | rect_gen([x - radius, y - radius], [x + radius, y + radius], g)
53 | }
54 | function close_line(...args) {line_gen(...args); last(args).closePath()}
55 | function diamond_around_gen([x, y], radius, g) {
56 | const r = radius; close_line([x - r, y], [x, y - r], [x + r, y], [x, y + r], g)
57 | }
58 | function signed_triangle_around_gen(sign, [x, y], radius, g) {
59 | const half_width = radius * Math.sqrt(3) / 2
60 | const y1 = y - radius * sign, y2 = y + radius / 2 * sign
61 | close_line([x, y1], [x - half_width, y2], [x + half_width, y2], g)
62 | }
63 | function triangle_around_gen(xy, radius, g) {
64 | signed_triangle_around_gen(1, xy, radius, g)
65 | }
66 | function rev_triangle_around_gen(xy, radius, g) {
67 | signed_triangle_around_gen(-1, xy, radius, g)
68 | }
69 |
70 | const [line, fill_line, edged_fill_line] = drawers_trio(line_gen)
71 | const [rect, fill_rect, edged_fill_rect] = drawers_trio(rect_gen)
72 | const [circle, fill_circle, edged_fill_circle] = drawers_trio(circle_gen)
73 | const [fan, fill_fan, edged_fill_fan] = drawers_trio(fan_gen)
74 | const [square_around, fill_square_around, edged_fill_square_around] =
75 | drawers_trio(square_around_gen)
76 | const [diamond_around, fill_diamond_around, edged_fill_diamond_around] =
77 | drawers_trio(diamond_around_gen)
78 | const [triangle_around, fill_triangle_around, edged_fill_triangle_around] =
79 | drawers_trio(triangle_around_gen)
80 | const [rev_triangle_around, fill_rev_triangle_around, edged_fill_rev_triangle_around] =
81 | drawers_trio(rev_triangle_around_gen)
82 |
83 | function x_shape_around([x, y], radius, g) {
84 | line([x - radius, y - radius], [x + radius, y + radius], g)
85 | line([x - radius, y + radius], [x + radius, y - radius], g)
86 | }
87 |
88 | function draw_square_image(img, [x, y], radius, g) {
89 | const shorter = Math.min(img.width, img.height), mag = radius / shorter
90 | const xrad = mag * img.width, yrad = mag * img.height
91 | g.drawImage(img, x - xrad, y - yrad, xrad * 2, yrad * 2)
92 | }
93 |
94 | // ref.
95 | // https://github.com/kaorahi/lizgoban/issues/30
96 | // https://stackoverflow.com/questions/53958949/createjs-canvas-text-position-changed-after-chrome-version-upgrade-from-70-to-71
97 | // https://bugs.chromium.org/p/chromium/issues/detail?id=607053
98 | const fix_baseline_p = process.versions.electron.match(/^[0-4]\./)
99 | function fill_text(g, fontsize, text, x, y, max_width) {
100 | fill_text_with_modifier(g, null, fontsize, text, x, y, max_width)
101 | }
102 | function fill_text_with_modifier(g, font_modifier, fontsize, text, x, y, max_width) {
103 | const sink = fix_baseline_p ? 0 : 0.07
104 | set_font((font_modifier || '') + fontsize, g)
105 | g.fillText(text, x, y + fontsize * sink, max_width)
106 | }
107 | function set_font(fontsize, g) {g.font = '' + fontsize + 'px Arial'}
108 |
109 | function side_gradation(x0, x1, color0, color1, g) {
110 | return gradation_gen(g.createLinearGradient(x0, 0, x1, 0), color0, color1, g)
111 | }
112 |
113 | function radial_gradation(x, y, radius0, radius1, color0, color1, g) {
114 | return skew_radial_gradation(x, y, radius0, x, y, radius1, color0, color1, g)
115 | }
116 |
117 | function skew_radial_gradation(x0, y0, radius0, x1, y1, radius1, color0, color1, g) {
118 | return gradation_gen(g.createRadialGradient(x0, y0, radius0, x1, y1, radius1),
119 | color0, color1, g)
120 | }
121 |
122 | function gradation_gen(grad, color0, color1, g) {
123 | grad.addColorStop(0, color0); grad.addColorStop(1, color1)
124 | return grad
125 | }
126 |
127 | function hsla(h, s, l, alpha) {return `hsla(${h},${s}%,${l}%,${true_or(alpha, 1)})`}
128 |
129 | ////////////////////////////
130 | // math
131 |
132 | function css2phys(px) {return to_i(px * window.devicePixelRatio)}
133 | function phys2css(px) {return px / window.devicePixelRatio}
134 |
135 | function flip_maybe(x, bturn) {
136 | return (bturn === undefined ? R.bturn : bturn) ? x : 100 - x
137 | }
138 |
139 | function tics_until(max) {
140 | const v = Math.pow(10, Math.floor(log10(max)))
141 | const unit_v = (max > v * 5) ? v * 2 : (max > v * 2) ? v : v / 2
142 | return seq(to_i(max / unit_v + 2)).map(k => unit_v * k) // +1 for margin
143 | }
144 |
145 | function log10(z) {return Math.log(z) / Math.log(10)}
146 |
147 | function f2s(z, digits) {return truep(z) ? z.toFixed(truep(digits) ? digits : 1) : ''}
148 |
149 | ////////////////////////////
150 | // exports
151 |
152 | module.exports = {
153 | // color
154 | BLACK, WHITE,
155 | GRAY, DARK_GRAY,
156 | RED, GREEN, BLUE, YELLOW,
157 | ORANGE,
158 | DARK_YELLOW, TRANSPARENT,
159 | MAYBE_BLACK, MAYBE_WHITE,
160 | VAGUE_BLACK, VAGUE_WHITE,
161 | PALE_BLUE,
162 | PALE_BLACK, PALE_WHITE,
163 | PALER_BLACK, PALER_WHITE,
164 | PALE_RED, PALE_GREEN,
165 | WINRATE_TRAIL_COLOR,
166 | WINRATE_BAR_ORDER_COLOR, WINRATE_BAR_FIRST_ORDER_COLOR,
167 | EXPECTED_COLOR, UNEXPECTED_COLOR,
168 | GOBAN_BG_COLOR,
169 | // graphics
170 | clear_canvas,
171 | line, fill_line, edged_fill_line,
172 | rect, fill_rect, edged_fill_rect,
173 | circle, fill_circle, edged_fill_circle,
174 | fan, fill_fan, edged_fill_fan,
175 | square_around, fill_square_around, edged_fill_square_around,
176 | diamond_around, fill_diamond_around, edged_fill_diamond_around,
177 | triangle_around, fill_triangle_around, edged_fill_triangle_around,
178 | rev_triangle_around, fill_rev_triangle_around, edged_fill_rev_triangle_around,
179 | x_shape_around, draw_square_image,
180 | fill_text, fill_text_with_modifier, set_font,
181 | side_gradation, radial_gradation, skew_radial_gradation, hsla,
182 | // math
183 | css2phys, phys2css,
184 | flip_maybe, tics_until, log10, f2s,
185 | }
186 |
--------------------------------------------------------------------------------
/src/draw_endstate_dist.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | function draw_endstate_distribution(canvas) {
4 | const {komi, endstate_sum} = R
5 | const score_diff = truep(endstate_sum) && (endstate_sum - komi)
6 | const {ssg, es_leadings} = sorted_stone_groups(komi, score_diff)
7 | if (!ssg) {hide_endstate_distribution(canvas); return}
8 | const {width, height} = canvas, g = canvas.getContext("2d")
9 | const ss = ssg.flat(), points = ss.length + Math.abs(komi)
10 | clear_canvas(canvas, '#888')
11 | // upper
12 | const [p2x, x2p] = translator_pair([0, points], [0, width])
13 | const [o2y, y2o] = translator_pair([0, 1], [height * 0.5, 0])
14 | draw_endstate(ssg, komi, p2x, o2y, g)
15 | draw_endstate_mirror(ssg, komi, p2x, o2y, g)
16 | draw_grids(o2y, g)
17 | draw_score(ss, points, score_diff, komi, o2y, g)
18 | // lower
19 | const [s2x, x2s] = translator_pair([0, 1], [width * 0.05, width * 0.95])
20 | const [t2y, y2t] = translator_pair([1, -1], [height * 0.55, height * 0.98])
21 | draw_leadings(es_leadings, score_diff > 0, s2x, t2y, g)
22 | // overlay
23 | draw_amb_gain(width, height, g)
24 | // show
25 | show_endstate_distribution(canvas)
26 | }
27 |
28 | // fixme: ugly show/hide to hide border (see also hide_in_serious_match)
29 | function show_endstate_distribution(canvas) {canvas.dataset.show = 'yes'}
30 | function hide_endstate_distribution(canvas) {
31 | canvas.dataset.show = 'no'; clear_canvas(canvas)
32 | }
33 |
34 | ////////////////////////////////////////////
35 | // sort
36 |
37 | function sorted_stone_groups(komi, score_diff) {
38 | if (!truep((aa_ref(R.stones, 0, 0) || {}).immediate_endstate)) {return {}}
39 | // ssg
40 | const copy_immediate_endstate = s => ({...s, endstate: s.immediate_endstate})
41 | const flat_stones = R.stones.flat().map(copy_immediate_endstate)
42 | const pick = pred => sort_by(flat_stones.filter(pred), s => s.endstate)
43 | const bs = pick(s => s.stone && s.black)
44 | const ws = pick(s => s.stone && !s.black)
45 | const [alive_bs, dead_bs] = classify(bs)
46 | const [alive_ws, dead_ws] = classify(ws)
47 | const ts = pick(s => !s.stone)
48 | const left = [dead_ws.reverse(), alive_bs].flat()
49 | const middle = ts.reverse()
50 | const right = [alive_ws, dead_bs.reverse()].flat()
51 | const ssg = [left, middle, right]
52 | // leadings
53 | const es_leadings_rule = [
54 | {stone: true, settled: true, category: 0, emph: true, label: 'stone'},
55 | {stone: true, settled: false, category: 0, emph: false},
56 | {stone: false, settled: true, category: 1, emph: true, label: 'territory'},
57 | {stone: false, settled: false, category: 1, emph: false},
58 | {value: - komi, category: 2, emph: true, label: 'komi'},
59 | // skip several categories here for wider space before the "total" bar
60 | {value: score_diff, category: 5, emph: false, label: 'total', digits: true},
61 | ]
62 | const conditional_es_sum = (settled_p, stone_p) => {
63 | // Dead stones are counted as "territories" rather than "stones"
64 | // so that capturing of completely dead stones does not change
65 | // the counts suddenly.
66 | const eql = (a, b) => !!a === !!b
67 | const stony = s => s.stone && eql(s.black, s.endstate > 0)
68 | const is_target = s => eql(stony(s), stone_p)
69 | const weight = es => settled_p ? 1 - endstate_entropy(es) : endstate_entropy(es)
70 | const soft_count = es => es * weight(es)
71 | const f = (acc, s) => is_target(s) ? acc + soft_count(s.endstate) : acc
72 | return flat_stones.reduce(f, 0)
73 | }
74 | const apply_es_leadings_rule = h =>
75 | ({...h, ...(h.value === undefined) ? {value: conditional_es_sum(h.settled, h.stone)} : {}})
76 | const es_leadings = es_leadings_rule.map(apply_es_leadings_rule)
77 | // ret
78 | return {ssg, es_leadings}
79 | }
80 |
81 | function alive(s) {return s.endstate * (s.black ? +1 : -1) > 0}
82 | function dead(s) {return !alive(s)}
83 | function classify(ss) {return [ss.filter(alive), ss.filter(dead)]}
84 |
85 | ////////////////////////////////////////////
86 | // black/white regions
87 |
88 | function draw_endstate(ssg, komi, p2x, o2y, g) {
89 | const hotness_table = [
90 | // [stone_ambiguity threshold, hotness]
91 | [30, 2], [20, 1], [- Infinity, 0]
92 | ]
93 | const [left, middle, right] = ssg, ss = ssg.flat()
94 | const stone_ambiguity = sum(ss.map(s => s.stone ? endstate_entropy(s.endstate) : 0)) * 0.5
95 | const hot = hotness_table.find(([t, _]) => stone_ambiguity >= t)[1]
96 | const {b_komi, w_komi, bk_offset, wk_offset, l_offset, m_offset, r_offset}
97 | = param_for(left, middle, right, komi)
98 | const draw_part = (ss, offset) => {
99 | const draw = (s, k) =>
100 | rect2(k + offset, Math.abs(s.endstate), colors_for(s, hot), p2x, o2y, g)
101 | ss.forEach(draw)
102 | }
103 | draw_komi(b_komi, bk_offset, '#000', p2x, o2y, g)
104 | draw_komi(w_komi, wk_offset, '#fff', p2x, o2y, g)
105 | draw_part(left, l_offset)
106 | draw_part(middle, m_offset)
107 | draw_part(right, r_offset)
108 | }
109 |
110 | function param_for(left, middle, right, komi) {
111 | const {b_komi, w_komi} = bw_komi(komi)
112 | const lengths = [left.length, b_komi, middle.length, w_komi]
113 | let length_sum = 0
114 | const [l_offset, bk_offset, m_offset, wk_offset, r_offset] =
115 | [0, ...lengths.map(w => length_sum += w)]
116 | return {b_komi, w_komi, bk_offset, wk_offset, l_offset, m_offset, r_offset}
117 | }
118 |
119 | function colors_for(s, hot) {
120 | const black_color = '#222', alt_black_color = '#444'
121 | const white_color = '#eee', alt_white_color = '#ccc'
122 | const hot_color = [ORANGE, '#f00', '#60f']
123 | const territory_void_color = '#888'
124 | const stone_void_color = truep(R.endstate_sum) ?
125 | hot_color[hot] : territory_void_color
126 | const if_alive = (a, d) => s.stone && alive(s) ? a : d
127 | const void_color = s.stone ? stone_void_color : territory_void_color
128 | const owner_color = s.endstate >= 0 ?
129 | if_alive(black_color, alt_black_color) :
130 | if_alive(white_color, alt_white_color)
131 | return [void_color, owner_color]
132 | }
133 |
134 | function rect2(k, ownership, [color0, color1], p2x, o2y, g) {
135 | const x0 = p2x(k), x1 = p2x(k + 1), y = o2y(ownership), y0 = o2y(0)
136 | rect1([x0, 0], [x1, y], color0, g)
137 | rect1([x0, y], [x1, y0], color1, g)
138 | }
139 |
140 | function rect1([x0, y0], [x1, y1], color, g) {
141 | const eps = 0.5
142 | g.fillStyle = color
143 | fill_rect([x0 - eps, y0], [x1 + eps, y1], g)
144 | }
145 |
146 | function draw_komi(komi, offset, color, p2x, o2y, g) {
147 | const epsilon = 1
148 | const {top, bottom} = get_geometry(o2y, g)
149 | const top_left = [p2x(offset) - epsilon, top]
150 | const bottom_right =[p2x(offset + komi) + epsilon, bottom]
151 | g.fillStyle = color; fill_rect(top_left, bottom_right, g)
152 | }
153 |
154 | ////////////////////////////////////////////
155 | // mirror outlines
156 |
157 | function draw_endstate_mirror(ssg, komi, p2x, o2y, g) {
158 | const reverse_ss = ss => ss.slice().reverse()
159 | const mirrored_ssg = ssg.map(reverse_ss).reverse()
160 | draw_endstate_outline(mirrored_ssg, - komi, p2x, o2y, g)
161 | }
162 |
163 | function draw_endstate_outline(ssg, komi, p2x, o2y, g) {
164 | g.lineWidth = 2
165 | const black_color = 'rgba(255,255,255,0.5)', white_color = 'rgba(0,0,0,0.5)'
166 | const [left, middle, right] = ssg
167 | const {b_komi, w_komi, bk_offset, wk_offset, l_offset, m_offset, r_offset}
168 | = param_for(left, middle, right, komi)
169 | const k2x = k => p2x(k + 0.5)
170 | const xyc_list_for = (ss, offset) => {
171 | const xyc = ({endstate}, k) => {
172 | const x = k2x(k + offset), y = o2y(Math.abs(endstate))
173 | const c = endstate < 0 ? black_color : white_color
174 | return [x, y, c]
175 | }
176 | return ss.map(xyc)
177 | }
178 | const komi_xyc_list = (komi, offset, color) =>
179 | komi > 0 ? [[k2x(offset), 0, color], [k2x(komi + offset - 1), 0, color]] : []
180 | const xyc_list = [
181 | xyc_list_for(left, l_offset),
182 | komi_xyc_list(b_komi, bk_offset, black_color),
183 | xyc_list_for(middle, m_offset),
184 | komi_xyc_list(w_komi, wk_offset, white_color),
185 | xyc_list_for(right, r_offset),
186 | ].flat()
187 | const draw = ([x, y, c], k, a) => {
188 | const [x0, y0, _] = a[k - 1] || []; if (!truep(x)) {return}
189 | g.strokeStyle = c
190 | line([x0, y0], [x, y], g)
191 | }
192 | xyc_list.forEach(draw)
193 | }
194 |
195 | ////////////////////////////////////////////
196 | // grid lines
197 |
198 | function draw_grids(o2y, g) {
199 | const x_grids = 2, y_grids = 3, color = ORANGE, line_width = 1
200 | const {left, right, top, bottom, width, height} = get_geometry(o2y, g)
201 | const horizontal_line = (y, g) => {line([left, y], [right, y], g)}
202 | const vertical_line = (x, g) => {line([x, top], [x, bottom], g)}
203 | const grid_lines = (n, size, plotter) =>
204 | seq(n - 1, 1).forEach(k => plotter(k * size / n, g))
205 | g.strokeStyle = color; g.lineWidth = line_width
206 | grid_lines(x_grids, width, vertical_line)
207 | grid_lines(y_grids, height, horizontal_line)
208 | }
209 |
210 | ////////////////////////////////////////////
211 | // score lead rectangle
212 |
213 | function draw_score(ss, points, score_diff, komi, o2y, g) {
214 | if (!truep(score_diff)) {return}
215 | const {width, height} = get_geometry(o2y, g)
216 | //
217 | const score_sum = sum(ss.map(s => Math.abs(s.endstate))) + Math.abs(komi)
218 | const average_height = score_sum / points * height
219 | const average_y = o2y(score_sum / points)
220 | // g.lineWidth = 1
221 | // g.strokeStyle = score_diff > 0 ? '#000' : '#fff'
222 | // line([0, average_y], [width, average_y], g)
223 | //
224 | const same_area_rectangle_for_square = (a, max_height) =>
225 | (a > max_height) ? [a**2 / max_height, max_height] : [a, a]
226 | const unit_area = width * height / points
227 | const l = Math.sqrt(Math.abs(score_diff) * unit_area)
228 | const [w, h] = same_area_rectangle_for_square(l, average_height)
229 | const center_x = width / 2, half_w = w / 2
230 | // > 'fc 8d 49'.split(/ /).map(s => parseInt(s, 16))
231 | // [ 252, 141, 73 ]
232 | g.lineWidth = 2
233 | g.strokeStyle = score_diff > 0 ? '#000' : '#fff'
234 | g.fillStyle = 'rgba(252,141,73,0.5)'
235 | edged_fill_rect([center_x - half_w, average_y],
236 | [center_x + half_w, average_y + h], g)
237 | }
238 |
239 | ////////////////////////////////////////////
240 | // leadings
241 |
242 | function draw_leadings(es_leadings, is_black_leading, s2x, t2y, g) {
243 | // coord
244 | const max_t = Math.max(...es_leadings.map(h => Math.abs(h.value)), 10)
245 | const scr = (s, t) => [s2x(s), t2y(clip(t / max_t, -1, 1))]
246 | const n = es_leadings.length
247 | const last_category = last(es_leadings).category
248 | const sep_width = 0.05
249 | const bar_width = (1 - sep_width * last_category) / n
250 | const i2s = (i, category) => bar_width * i + sep_width * category
251 | // bars
252 | let i = 0, label_request = [], digits_request = []
253 | es_leadings.forEach(h => {
254 | const ok = truep(h.value)
255 | // TRANSPARENT for avoiding any color for "no stone", "no komi", etc.
256 | const color = h.value === 0 ? TRANSPARENT : h.value > 0 ? BLACK : WHITE
257 | const [strokeStyle, fillStyle] =
258 | h.emph ? [TRANSPARENT, color] : [color, TRANSPARENT]
259 | merge(g, {strokeStyle, fillStyle}); g.lineWidth = 3
260 | const s = i2s(i, h.category)
261 | ok && edged_fill_rect(scr(s, 0), scr(s + bar_width, h.value), g)
262 | h.label && label_request.push([s, h.label])
263 | ok && h.digits && digits_request.push([s + bar_width / 2, h.value])
264 | i++
265 | })
266 | // base line
267 | g.strokeStyle = ORANGE; g.lineWidth = 1
268 | line(scr(0, 0), scr(1, 0), g)
269 | // labels
270 | const width = s2x(1) - s2x(0), height = t2y(-1) - t2y(1)
271 | const fontsize = Math.min(width * 0.07, height * 0.25)
272 | g.save()
273 | g.fillStyle = WHITE
274 | g.textBaseline = 'top'
275 | label_request.forEach(([s, label]) =>
276 | fill_text(g, fontsize, label, ...scr(s, Infinity)))
277 | g.restore()
278 | // digits
279 | g.save()
280 | g.textAlign = 'center'
281 | digits_request.forEach(([s, v]) => {
282 | const [fillStyle, textBaseline, vpos, header] =
283 | v > 0 ? [BLACK, 'top', -0.1, 'B'] :
284 | v < 0 ? [WHITE, 'bottom', 0.1, 'W'] :
285 | [TRANSPARENT, '', 0]
286 | const text = `${header}+${f2s(Math.abs(v))}`
287 | merge(g, {fillStyle, textBaseline})
288 | fill_text(g, fontsize, text, ...scr(s, vpos * max_t), width * 0.2)
289 | })
290 | g.restore()
291 | }
292 |
293 | ////////////////////////////////////////////
294 | // amb gain
295 |
296 | function draw_amb_gain(width, height, g) {
297 | const black_color = 'rgba(0,255,0,0.5)', white_color = 'rgba(255,0,255,0.5)'
298 | const hx = width * 0.5, hy = height * 0.5
299 | const table = [[true, black_color], [false, white_color]]
300 | R.bturn || table.reverse()
301 | table.map(a => draw_amb_gain_sub(...a, hx, hy, g))
302 | }
303 |
304 | function draw_amb_gain_sub(is_black, color, hx, hy, g) {
305 | const line_width = 4, relative_radius = 0.08
306 | const amb_scale = 0.33, moyo_scale = 0.33, pow = 3.0
307 | const r = Math.min(hx, hy)
308 | const {amb_gain} = R.move_history[R.move_count] || {}; if (!amb_gain) {return}
309 | const {ambiguity_gain, moyolead_gain} = amb_gain
310 | const f = (a, scale) => amb_emphasize(a[is_black] * scale, pow)
311 | const amb = f(ambiguity_gain, amb_scale)
312 | const moyo = f(moyolead_gain, moyo_scale)
313 | const hxy = [hx, hy], xy = [hx + amb * r, hy - moyo * r]
314 | g.lineWidth = line_width; g.strokeStyle = g.fillStyle = color
315 | line(hxy, xy, g); fill_circle(xy, r * relative_radius, g)
316 | }
317 |
318 | function amb_emphasize(orig, power) {
319 | const conv = z => 1 - (1 - z)**power // convert [0,1] to [0,1]
320 | return Math.tanh(Math.sign(orig) * conv(Math.abs(orig)))
321 | }
322 |
323 | /////////////////////////////////////////////////
324 | // util
325 |
326 | function get_geometry(o2y, g) {
327 | const left = 0, right = g.canvas.width, top = o2y(1), bottom = o2y(0)
328 | const width = right - left, height = bottom - top
329 | return {left, right, top, bottom, width, height}
330 | }
331 |
332 | function bw_komi(komi) {
333 | const b_komi = clip(- komi, 0), w_komi = clip(komi, 0)
334 | return {b_komi, w_komi}
335 | }
336 |
337 | /////////////////////////////////////////////////
338 | // exports
339 |
340 | module.exports = {
341 | draw_endstate_distribution,
342 | hide_endstate_distribution,
343 | }
344 |
--------------------------------------------------------------------------------
/src/draw_visits_trail.js:
--------------------------------------------------------------------------------
1 | /////////////////////////////////////////////////
2 | // visits trail
3 |
4 | function draw_visits_trail_sub(winrate_trail, canvas) {
5 | const w = canvas.width, h = canvas.height, g = canvas.getContext("2d")
6 | const fontsize = h / 10, top_margin = 3
7 | const v2x = v => v / R.visits * w
8 | const v2y = v => (1 - v / R.max_visits) * (h - top_margin) + top_margin
9 | const xy_for = z => [v2x(z.total_visits), v2y(z.visits)]
10 | canvas.onmousedown = canvas.onmousemove = canvas.onmouseup = e => {}
11 | g.fillStyle = BLACK; fill_rect([0, 0], [w, h], g)
12 | if (!R.visits || !R.max_visits) {return}
13 | draw_visits_trail_grid(fontsize, w, h, v2x, v2y, g)
14 | R.suggest.forEach(s => draw_visits_trail_curve(s, winrate_trail, fontsize, h, xy_for, g))
15 | draw_visits_trail_background_visits(w, h, v2x, g)
16 | }
17 |
18 | function draw_visits_trail_grid(fontsize, w, h, v2x, v2y, g) {
19 | const kilo = (v, x, y) => fill_text(g, fontsize, ' ' + kilo_str(v).replace('.0', ''), x, y)
20 | g.save()
21 | g.lineWidth = 1
22 | g.strokeStyle = g.fillStyle = WINRATE_TRAIL_COLOR; g.textAlign = 'left'
23 | g.textBaseline = 'top'
24 | tics_until(R.visits).forEach(v => {
25 | if (!v) {return}; const x = v2x(v); line([x, 0], [x, h], g); kilo(v, x, 0)
26 | })
27 | g.textBaseline = 'bottom'
28 | tics_until(R.max_visits).forEach(v => {
29 | if (!v) {return}; const y = v2y(v); line([0, y], [w, y], g); kilo(v, 0, y)
30 | })
31 | g.restore()
32 | }
33 |
34 | function draw_visits_trail_curve(s, winrate_trail, fontsize, h, xy_for, g) {
35 | const {move} = s, a = winrate_trail[move]
36 | if (!a) {return}
37 | const {alpha, target_p, draw_order_p, next_p} = winrate_bar_suggest_prop(s)
38 | const xy = a.map(xy_for)
39 | a.forEach((fake_suggest, k) => { // only use fake_suggest.winrate
40 | if (k === 0) {return}
41 | g.strokeStyle = g.fillStyle = suggest_color(fake_suggest, alpha).fill
42 | g.lineWidth = (a[k].order === 0 && a[k-1].order === 0) ? 8 : 2
43 | line(xy[k], xy[k - 1], g)
44 | next_p && !target_p && fill_circle(xy[k], 4, g)
45 | })
46 | draw_order_p && draw_visits_trail_order(s, a, target_p, fontsize, h, xy_for, g)
47 | }
48 |
49 | function draw_visits_trail_order(s, a, forcep, fontsize, h, xy_for, g) {
50 | const [x, y] = xy_for(a[0]), low = y > 0.8 * h, ord = s.order + 1
51 | if (low && !forcep) {return}
52 | g.save()
53 | g.textAlign = 'right'; g.textBaseline = low ? 'bottom' : 'top'
54 | const modified_fontsize = winrate_bar_order_set_style(s, fontsize, g)
55 | fill_text(g, modified_fontsize, ord === 1 ? '1' : `${ord} `, x, y)
56 | g.restore()
57 | }
58 |
59 | function draw_visits_trail_background_visits(w, h, v2x, g) {
60 | if (!truep(R.background_visits)) {return}
61 | const x = v2x(R.background_visits)
62 | g.save()
63 | g.strokeStyle = GREEN; g.lineWidth = 3; line([x, 0], [x, h], g)
64 | g.fillStyle = '#888'; g.textAlign = 'center'; g.textBaseline = 'middle'
65 | fill_text(g, h / 5, 'Reused', w / 2, h / 2)
66 | g.restore()
67 | }
68 |
69 | /////////////////////////////////////////////////
70 | // exports
71 |
72 | module.exports = {
73 | draw_visits_trail_sub,
74 | }
75 |
--------------------------------------------------------------------------------
/src/exercise.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | //////////////////////////////////////
4 | // exports
5 |
6 | let exercise_mtime
7 |
8 | module.exports = (...a) => {
9 | [exercise_mtime] = a
10 | return {
11 | exercise_filename,
12 | is_exercise_filename,
13 | exercise_move_count,
14 | exercise_board_size,
15 | update_exercise_metadata_for,
16 | get_all_exercise_metadata: get_metadata,
17 | random_exercise_chooser,
18 | recent_exercise_chooser,
19 | recently_seen_exercises_in,
20 | }
21 | }
22 |
23 | /////////////////////////////////////////////////
24 | // file name format
25 |
26 | const exercise_format = {pre: 'exercise', sep: '_', post: '.sgf'}
27 | function exercise_filename(game, format) {
28 | const {pre, sep, post} = format || exercise_format
29 | const mc = to_s(game.move_count).padStart(3, '0')
30 | const ti = (new Date()).toJSON().replace(/:/g, '') // cannot use ":" in Windows
31 | return `${pre}${board_size()}${sep}${ti}${sep}${mc}${post}`
32 | }
33 | function is_exercise_filename(filename) {
34 | const {pre, sep, post} = exercise_format
35 | return filename.startsWith(pre) && filename.endsWith(post)
36 | }
37 | function exercise_move_count(filename) {
38 | const {pre, sep, post} = exercise_format
39 | return to_i(last(filename.split(sep)).split(post)[0])
40 | }
41 | function exercise_board_size(filename) {
42 | const {pre, sep, post} = exercise_format
43 | return to_i(filename.split(sep)[0].split(pre)[1] || 19)
44 | }
45 |
46 | /////////////////////////////////////////////////
47 | // metadata
48 |
49 | const log_length_per_exercise = 2
50 |
51 | const stored_exercise_info = new ELECTRON_STORE({name: 'lizgoban_exercise_info'})
52 | function get_metadata() {return stored_exercise_info.get('metadata', {})}
53 | function set_metadata(metadata) {stored_exercise_info.set('metadata', metadata)}
54 | function update_metadata(updater) {
55 | const md = get_metadata(); updater(md); set_metadata(md); return md
56 | }
57 |
58 | function initial_exercise_metadata() {return {stars: 0, seen_at: []}}
59 |
60 | function update_exercise_metadata_for(filename, {seen_at, stars}) {
61 | const updater = metadata => {
62 | const prop = hash_ref(metadata, filename, initial_exercise_metadata)
63 | seen_at && prepend_log(hash_ref(prop, 'seen_at', []), seen_at)
64 | truep(stars) && merge(prop, {stars})
65 | }
66 | return update_metadata(updater)[filename]
67 | }
68 |
69 | function hash_ref(hash, key, missing) {
70 | let val = hash[key], valid = (val !== undefined)
71 | return valid ? val : (hash[key] = functionp(missing) ? missing() : missing)
72 | }
73 |
74 | function prepend_log(log, value) {
75 | log.unshift(value); log.splice(log_length_per_exercise)
76 | }
77 |
78 | /////////////////////////////////////////////////
79 | // chooser
80 |
81 | function random_exercise_chooser(a, metadata) {
82 | const coin_toss = (Math.random() < 0.5)
83 | const prefer_recent = coin_toss ? 0 : 0.1, prefer_stars = Math.log(2)
84 | const recently_seen = fn => {
85 | const last_seen = ((metadata[fn] || {}).seen_at || [])[0]
86 | return - new Date(last_seen || exercise_mtime(fn))
87 | }
88 | const sorted = sort_by(a, recently_seen)
89 | const weight_of = (fn, k) => {
90 | const {stars} = metadata[fn] || {}
91 | const preferred = prefer_recent * (- k) + prefer_stars * (stars || 0)
92 | return Math.exp(preferred)
93 | }
94 | return weighted_random_choice(sorted, weight_of)
95 | }
96 |
97 | function recent_exercise_chooser(a) {
98 | const neg_mtime = fn => - exercise_mtime(fn)
99 | return min_by(a, neg_mtime)
100 | }
101 |
102 | /////////////////////////////////////////////////
103 | // seen
104 |
105 | function recently_seen_exercises_in(exercises, metadata, hours) {
106 | const now = new Date(), ms_in_h = 60 * 60 * 1000, recent = hours * ms_in_h
107 | const recent_p = fn => {
108 | const last_seen = ((metadata[fn] || {}).seen_at || [])[0]
109 | return (now - new Date(last_seen || 0) < recent)
110 | }
111 | return exercises.filter(recent_p)
112 | }
113 |
--------------------------------------------------------------------------------
/src/fast_redo.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const fast_redo_moves_per_sec = [
4 | // [delay (sec), moves_per_sec]
5 | [0.0, 3],
6 | [1.0, 20],
7 | [2.0, 100],
8 | ]
9 | const fast_redo_drawing_interval_millisec = 20
10 |
11 | let fast_redo_request = null, fast_redo_timer = null
12 |
13 | function start_fast_redo(proc) {
14 | if (fast_redo_request) {return}
15 | const time = Date.now()
16 | const mps_after_sec = piecewise_linear(fast_redo_moves_per_sec)
17 | const moves_per_sec = t => mps_after_sec((t - time) / 1000)
18 | fast_redo_request = {time, moves_per_sec, proc, next_check_at: time}
19 | try_fast_redo()
20 | }
21 | function stop_fast_redo() {clearTimeout(fast_redo_timer); fast_redo_request = null}
22 |
23 | function try_fast_redo() {
24 | const req = fast_redo_request; if (!req) {return}
25 | const delay = clip(req.next_check_at - Date.now(), 0)
26 | clearTimeout(fast_redo_timer)
27 | fast_redo_timer = setTimeout(try_fast_redo_now, delay)
28 | }
29 | function try_fast_redo_now() {
30 | const req = fast_redo_request
31 | if (!req || req.move_count === R.move_count) {return}
32 | const now = Date.now(), dt_sec = (now - req.time) / 1000
33 | const moves = Math.round(req.moves_per_sec(now) * dt_sec)
34 | req.next_check_at = now + fast_redo_drawing_interval_millisec
35 | if (moves < 1) {try_fast_redo(); return} // after updating of next_check_at
36 | req.move_count = R.move_count
37 | req.time = now
38 | req.proc(moves)
39 | }
40 |
41 | function piecewise_linear(pairs) {
42 | // (ex.)
43 | // g = piecewise_linear([[0, 1], [2, 3], [5, 9]])
44 | // seq(10, -2).map(x => [x, g(x)])
45 | // ==> [[-2,1],[-1,1],[0,1],[1,2],[2,3],[3,5],[4,7],[5,9],[6,9],[7,9]]
46 | const x_f_table = pairs.map(([x0, y0], k, a) => {
47 | const next = a[k + 1]; if (!next) {return null}
48 | const [x1, y1] = next, x = k > 0 ? x0 : - Infinity
49 | const f = clipped_translator([x0, x1], [y0, y1])
50 | return {x, f}
51 | }).filter(truep).reverse()
52 | return x => x_f_table.find(h => x >= h.x).f(x)
53 | }
54 |
55 | //////////////////////////////////////
56 | // exports
57 |
58 | module.exports = {
59 | start_fast_redo,
60 | stop_fast_redo,
61 | try_fast_redo,
62 | }
63 |
--------------------------------------------------------------------------------
/src/globalize.js:
--------------------------------------------------------------------------------
1 | // ugly!
2 | function globalize(...args) {Object.assign(global, ...args)}
3 | module.exports = {globalize}
4 |
--------------------------------------------------------------------------------
/src/help.css:
--------------------------------------------------------------------------------
1 | body, .nav {color: black; background-color: #eee; margin-bottom: 0;}
2 | .ext {color: blue; text-decoration: underline; cursor: pointer;}
3 | .nav {position: -webkit-sticky; position: sticky; z-index: 1; bottom: 0;}
4 | .nav a {color: red;}
5 |
--------------------------------------------------------------------------------
/src/help.js:
--------------------------------------------------------------------------------
1 | let electron; try {electron = require('electron')} catch {}
2 | const version = electron ? electron.ipcRenderer.sendSync('app_version') : ''
3 | const open_ext = electron ? electron.shell.openExternal : window.open
4 | function for_class(name, proc) {
5 | Array.prototype.forEach.call(document.getElementsByClassName(name), proc)
6 | }
7 | window.onload = () => {
8 | for_class('ver', z => z.textContent = version)
9 | for_class('ext', z => {
10 | z.title = z.dataset.url; z.onclick = () => open_ext(z.dataset.url)
11 | })
12 | }
13 |
--------------------------------------------------------------------------------
/src/help_ja.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | LizGobanヘルプ
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | LizGobanは、Leela Zero とKataGo を用いた囲碁の分析ツールです。
16 |
17 |
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 | Ctrlなど「意味のないキー」を押し離しすれば表示が更新されます。
49 |
50 |
51 | 右クリック:前の手と同じ色の石を打つ(この機能は将来廃止されるかもしれません)
52 | 石をクリック:そのときの盤面を一時的に表示
53 | 石をダブルクリック:その手を打つ直前の盤面へ
54 | Altキーを押しながらドラッグ:分析する領域を制限 (Alt+クリック:領域制限を解除)
55 |
56 | この領域は、碁盤上の石の配置をコピー・貼りつけする機能でも使われます ([Edit] > [Flip / rotate / etc.] > [copy stones] および [paste])
57 |
58 |
59 | CtrlキーとShiftキーを押しながらクリック:棋譜を編集
60 |
61 | (空点上で)その手を挿入
62 | (石上で)その手を削除
63 |
64 |
65 | bキーかwキーを押しながらクリック:色を固定して同上
66 |
67 |
68 |
69 |
70 | [分岐]
71 |
72 | 点線四角:分岐(=子ノード)
73 | 点線三角:一手前の分岐(=兄弟ノード)
74 |
75 | 上記マーク内のタグ文字「d」「e」…に対応するキーを押し続ければ分岐をプレビューできます。その状態でEnterキーを押すと、別の検討用碁盤でその分岐を展開します。あるいは、点線四角をクリックしても分岐を展開できます。
76 |
77 |
78 |
79 | 直近の着手に対して、その手を打つ前と打った後とで第一候補手が変わった場合には、それぞれに青と赤の四角い枠が表示されます。これによってエンジンの意見の変化に気づくことができるでしょう。この枠の角についている黒色/白色は、黒番時/白番時の意見を表します(訳注:当初は次にここへ打つ想定だったけれど、前の手を打たれてから考え直したらこちらがよさそう、等)。同様の枠は変化図にも表示されます。これは特にAI対AIを観戦しているときに便利です。たとえば「8手先で開いてくると黒は読んでいるけれど、白はそこで切るつもりだ!」などがわかります。(「Engine」メニューの「白番は別のウェイトを使う(Alternative weights for white)」や、「Tool」メニューの「AI対AI (AI vs AI)」を参照。詳しく見るには「隣に並べて比較 」も参照)
80 |
81 |
82 |
83 | また、「View」メニューで「Lizzie風(Lizzie style)」を選択することもできます(上の数字=勝率、下の数字=読んだ手数)。KataGoを使用し、「View」メニューの「スコアバー(Score bar)」にチェックを入れると、上の数字がスコアに置き換わります。
84 |
85 |
86 |
87 | (シチョウ表示)
88 | シチョウを打つと、シチョウのぶつかり先に「=」印がつきます。「=」キーを押し続ければシチョウの続きを表示し、その状態でさらにEnterキーを押すと続きを実際に打ちます(検討用の別碁盤上で)。「=」キーで一度シチョウを表示しておけば、そのあと盤面が変わって「=」印が消えても、また「=」キーを押して「さっきのシチョウは今だとどうか」を試すこともできます。ただし今のところシチョウ検出が雑なので、シチョウでない場合もときどき誤って「=」印がついてしまいます。
89 |
90 |
91 | 勝率バー
92 |
93 |
94 | 黒/白の範囲:現在の盤面の勝率
95 | 緑・赤の四角:直近の着手による勝率の変化(緑=良、赤=悪)
96 | 扇型:各候補手の評価(上下位置=読んだ手数、大きさ=事前確率、青=実際に打たれた手)
97 | 逆向きで白抜きの扇型:直近の着手の評価(その手を実際に打つ前に予想していた評価)
98 | 青の菱形:「実際に打たれた次の一手」の着手後の評価
99 | 灰色の数字:一番読んだ手の読み手数(=勝率バーの最上部)
100 | 灰色の軌跡:探索の進行による評価の推移
101 |
102 |
103 | 「x」キーを押し続けると、勝率バーが拡大されます。「View」メニューの「勝率バーの拡大(Expand winrate bar)」も参照してください。勝率バーを拡大しているときは、今どの手を読んでいるかがオレンジ色の丸で表示されます。丸の大きさは注力の度合です。さらに、オレンジ色の斜めの線がPUCT(分析の優先度)(x軸=PUCT、y軸=現在の勝率+PUCTで予測される将来の位置)、オレンジ色の横線がLCB(推奨順位の基準)を示します。
104 |
105 | 勝率グラフ
106 |
107 | 主線:黒の勝率
108 |
109 | 緑の線分:好手(勝率アップ)
110 | 赤の線分:悪手(勝率ダウン)
111 | 黄色の線分:エンジンが見逃した予想外の好手
112 | 黄色の縦線:評価の誤差(「着手前に予想した評価」と「実際の着手後の評価」との差)
113 | 紫色の薄い線分:他のエンジンによる推定
114 |
115 |
116 | 追加プロット[K=KataGoのみ]
117 |
118 | オレンジ色の点:黒から見ての推定目差[K]
119 | 緑/ピンクの階段:黒/白の累積スコア損失(訳注:最善手とくらべて、のべ何目損したか)[K]
120 | 赤線:石の死活のあいまいさ(100%活きや100%死にだと低下。訳注:どれくらい激しい碁か、どのあたりからヨセか、などの目安)[K]
121 | ごく薄い灰色線:石および空点のあいまいさ(100%黒や100%白だと低下。訳注:これがほぼゼロになれば終局) [K]
122 | ごく薄い緑/ピンクの点:ある程度確定した黒地/白地(+コミ)。付随した縦線は、これらの確定地を除いた目差。もし緑の縦線がピンクの点より上までのびていたら、「黒はいま見えている地が少なくても、厚みや模様など潜在的な地が多く、総合的にはリードしている」という意味 [K]
123 | 上部の交互に並んだ円:コウ争い(o / x=ツギ/抜きで解消)
124 | 薄い赤の背景:死活のあいまいな石が多い期間 [K]
125 | 左端の灰色ラベル:推定段級位(KataGoの人間風ポリシーにもとづく推定値。人間風モデルが使える場合のみ表示。上側が黒、下側が白。デフォルトでは9d, 3d, 1k, 6k, 15kのいずれか。遅いがより細かい推定をするには、メニューの`Edit > Preferences > Finer dan/kyu scan`をチェック) [K]
126 | 薄い青の縦線:どの段級位が打ちそうな手か(上述の段級位推定と対応) [K]
127 | 上下のグラフの境目にある虹色帯:黒と白のプレイスタイルの差。緑/ピンク(青/オレンジ)=「黒のほうが白よりも、石(地)のあいまいさを増やす/減らす手を打っている」(次のゾーンカラーマップと勢力分布図 の緑・ピンク線を参照) [K]
128 | グラフ横の虹色の四角:ゾーンカラーマップ(訳注:着手場所と色の対応関係)
129 |
130 |
131 | グラフ上でクリック/ドラッグすると、対応する手に移動します。「x」キーを押し続けると、勝率グラフとゾーンカラーマップが拡大表示されます。「c」キーを押しながらグラフ上にマウスをかざすと、過去/未来の碁盤が一時的に表示されます。AI対AIを観戦しているときは、緑とピンクの線がそれぞれ黒と白のエンジンによる推定勝率を表します。
132 |
133 | ウィンドウタイトル
134 |
135 | エンジン名などの情報はウィンドウタイトルに表示されます。
136 |
137 | コメント
138 |
139 | 直前の着手に対するコメントは勝率グラフやボタン群の下に表示されます。コメントが長い場合はクリックすると全体を見ることができます(同時にクリップボードにもコピーされます)。コメントを編集するには「Edit」メニューの「Info」を使ってください。
140 |
141 | 解析手数グラフ
142 |
143 | 横向きレイアウト の場合にはもう一つ、解析した手数の経過を表すグラフがウィンドウの右下に小さく表示されます。このグラフを拡大するには、「View」メニューで「double_boards」を選択して「x」キーを押してください(訳注:勝率グラフのあった位置に大きく表示されます)。
144 |
145 |
146 | x軸:この盤面で読んだ手数の総計
147 | y軸:各候補手を読んだ回数
148 | 点々つきの線:実際に打たれた手
149 | 太線:最善手
150 |
151 |
152 | 碁盤上のその他のマークなど
153 |
154 | KataGoの項 を参照してください。
155 |
156 |
157 |
158 |
159 |
160 | 便利キー
161 |
162 | マウス/タッチパッド派の方でも、キーボード左下の4つのキー「z」「x」「c」 「v」は使うと便利でしょう。一つずつ押してみて、押しているあいだは碁盤の表示がどう変わるか試してください。ただし「v」キー はKataGo専用です。
163 |
164 |
165 |
166 | 既存の対局に対して別の手を打つと、新しい碁盤が作成されます。碁盤の縁に違う色がついているのが「検討用碁盤」を表します。検討用碁盤は、「q」キー で手早く削除することができます(削除の取り消し:Ctrl + z)。「Edit」メニューから「検討用碁盤(trial board)」のチェックを外すと、ふつうの碁盤になります。石を打つとき「クリック」の代わりに「Ctrl+クリック」を使えば、新しい検討用碁盤でその手を打つことができます。
167 |
168 | ドラッグ&ドロップ
169 |
170 | SGFファイルやURL(訳注:Webブラウザのリンクなど)や盤面画像をLizGobanにドラッグ&ドロップして開くことができます。
171 |
172 | 消えた碁盤の復元
173 |
174 | 「Edit」メニューの「削除された盤を戻す(Undelete board)」はセッションをまたいで動作します。意図せずLizGobanを終了してしまった場合に消えた碁盤を復元するには、Ctrl+zを試してみてください。
175 |
176 | (手加減された)エンジンとの対戦
177 |
178 |
179 | 「File」メニューの「Match vs AI」をクリックして、「vs.」の横のプルダウンメニューからAIの戦略を選んでください。
180 |
181 | normal: 手加減なしの全力
182 | diverse: 全力だが序盤はランダムな変化を加える
183 |
184 | KataGo v1.15.0以降で`-human-model`オプションを指定した場合は、段級位スライダーでAIの強さを調整できます。また、以下の戦略も選べるようになります。
185 |
186 | persona: 様々な棋風のAI
187 | spar: 腕試しな盤面に誘導
188 | center/edge: 中央/盤端の手を好む
189 |
190 | 「persona」を選んだ場合には、「...」ボタンから対局相手を決められます。
191 |
192 | 棋風をランダムに生成: 「Generate」ボタンをクリック
193 | 仮想的な対局相手: 任意の名前を入力すると、その名前にもとづいた棋風の相手が生成されます。いろいろな名前を試してお気に入りの対局相手をみつけてください。
194 | あらかじめ設定された棋風: 「envy」など名前のついたボタンをクリック
195 |
196 |
197 |
198 |
199 | (古い戦略)
200 |
201 |
202 |
203 | pass: 勝率に余裕があるときはパスする
204 | weak n: n=1 (やや弱い) to n=9 (非常に弱い)
205 | -Xpt (KataGoのみ): X目くらい損な手を毎回選ぶ
206 | swap n: 異なるエンジンのランダム混合 (下記参照)
207 |
208 |
209 | ただし、強いエンジンはある程度の考慮時間を与えないと悪手を思いつかないかもしれません。
210 | 「swap n」は、確率10n%でランダムに黒番用と白番用のエンジンを入れかえます。
211 | (前もって「Engine」メニューか「Preset」メニューから、たとえばLZ38とLZ157のように異なる設定のエンジンを黒番用と白番用にセットしておく必要があります。)
212 |
213 |
214 |
215 | (今のところ、ウィンドウのサイズを変更することで横向き/縦向きのレイアウトを切り替えることができます。ただし後者はもう保守されておらず、将来のバージョンでは消去予定です。)
216 |
217 |
218 |
219 | 「View」メニューで「Let me think first」を選択すると、「Tool」メニューから「自動リプレイ(Auto replay)」や「AI対AI」を使った際、進行度0%〜50%の間はノーヒントの盤面を表示して50%〜100%の間は候補手を表示します。途中でこの表示を切り替えたくなったときには「Tab」キー を使用します。自動進行でないときに同じようなことをしたければ、「;」キー をくり返し押してください(訳注:押すたびに、「候補手を表示」「候補手を消して一手進む」をくり返す)。
220 |
221 | 個人練習帳
222 |
223 |
224 | [Tool]メニューの[練習問題として保存(Store as exercize)]で現在の盤面を記憶し、[練習問題(Exercize)]で記憶した盤面をランダムに表示することができます(訳注:記憶は「!」キー、出題は「?」キーでもできます)。対局モードを抜けるには[stop match]ボタンを押してください(そのあともし解析結果が表示されなければTabキーかZキーを押してください)。
225 |
226 |
227 |
228 | ★マークの横の「+」「-」ボタンを押せば、その練習問題の出題頻度を上げ下げできます。
229 |
230 |
231 | 盤面画像の取り込み
232 |
233 |
234 | 何かの盤面画像をコピーして、LizGoban上でCtrl+Vキーを押すかメニューの"Edit > Paste"を選べば、石の配置を取り込むことができます。画像をLizGoban上にドラッグ&ドロップしても同様です。
235 |
236 |
237 |
238 | この機能はごく簡易なものです。反射光のある実物の碁盤写真やマーク・数字・光沢がついた石では、設定調整や手動修正が必要かもしれません。それでもインターネット上の多くの詰碁画像や対局・解説動画の盤面などはこの機能を使って取り込むことができます。
239 |
240 |
241 | 死活問題の解析:詰碁用フレーム
242 |
243 |
244 | 問題図のとおりに石を置く。同じ色の石を続けて置くには右クリック。
245 | "Tool > Tsumego frame"を選んで残りの領域を埋める。
246 | スペースキーで解析を開始して推奨手を見る。
247 |
248 |
249 | 分析領域の制限については[マウス操作] を参照。
250 |
251 |
262 |
263 |
264 |
265 |
266 |
267 | 着手
268 |
269 | 上/左矢印キー:一手戻る(シフトキーを押しながらだと15手戻る)
270 | 下/右矢印キー:一手進む(シフトキーを押しながらだと15手進む)
271 | Homeキー:初手へ
272 | Endキー:最終手へ
273 | BS/Delキー:手を戻す(訳注:最後の一手ならハガす、それ以外は単に一手戻る)
274 | pキー:パスする
275 | #キー:コメントを表示する
276 |
277 |
278 | 初手(最終手)でさらに上/左矢印キー(下/右矢印キー)を押し続けると、Endキー(Homeキー)として機能します。
279 |
280 |
281 |
282 | Ctrl+n:新規対局
283 | Shift+n:新規碁盤
284 | Ctrl+d:碁盤の複製
285 | [:前の碁盤
286 | ]:次の碁盤
287 | Ctrl+x:碁盤の削除(Ctrl+z:削除の取り消し)
288 | Ctrl+w:碁盤の削除/ウィンドウを閉じる
289 | q:Ctrl+xと同じ (検討用碁盤(trial board) のみ)
290 |
291 |
292 | 分析
293 |
294 | スペースキー:分析の一時停止/再開
295 | a:自動分析の開始/停止(読む手数を入力してEnterキーを押して開始)
296 | Enter:最善手を打つ(Shift=5手進む)
297 | `(バッククォート):別の碁盤で最善手を打つ
298 | ,(コンマ):想定手順を打つ
299 | Altキーを押しながらhjkl:分析する領域を制限(Alt+[:領域制限を解除)
300 |
301 |
302 | 表示
303 |
304 | (押し続ける)
305 |
306 | z:候補手やマークなどを隠す (「View」メニューの一部のスタイルでは逆に候補手を一時表示)
307 | x:グラフやコメント表示を大きく
308 | c:着手番号と座標を表示
309 |
310 | + 石の上にマウスカーソルをかざす:その時点の盤面を表示
311 | + 石の上でマウスクリック:その手に移動
312 |
313 | 1, 2, ...., 9, 0:n番目の候補手の想定手順を表示 (0 = 実際に打たれた手からの想定手順)
314 |
315 | + Enter:その手を打つ
316 | + `(バッククォート):別の碁盤でその手を打つ
317 | + ,(コンマ):その想定手順を打つ
318 | +(Ctrlなど、「意味のないキー」を押して離す):表示されている想定手順を更新する
319 |
320 | 石上に表示された d, e, f, ...:タグ文字時点の盤面を表示(+ Enter:その手に移動)
321 | 空点に表示された d, e, f, ...:分岐を表示(+ Enter:その分岐に移動)
322 |
323 |
324 | 隣に並べて比較:
325 | 「View」メニューの「碁盤二枚 A (盤面+変化図) (Two boards A (main+PV))」を選んでおけば、二種類の手順を隣あわせで見くらべることができます。以下のキーを押し続けてください。
326 |
327 | 1:エンジンの想定図と実際の手順(黒番と白番とで違うエンジンを使っているときは、双方の想定図)
328 | 分岐のd, e, f, ...:変化図と本手順
329 |
330 |
331 | (普通に押す)
332 |
333 | Shift+z:表示の切り替え(現在の表示/素の碁盤)
334 | Tab:表示の切り替え(現在の表示/前の表示)
335 | ;(セミコロン):「まず考えさせて(Let me think first) 」スタイルで「次へ」
336 |
337 | 1回目=候補手を表示
338 | 2回目=候補手を消して一手進む
339 |
340 |
341 |
342 | SGF
343 |
344 | Ctrl+c:SGFをクリップボードにコピー
345 | Ctrl+v:クリップボードからSGF, URL, 盤面画像を貼り付ける
346 | Ctrl+o:SGF(GIB, UGI, …)ファイルを開く
347 | Ctrl+s:SGFファイルの保存
348 |
349 |
350 | エンジン
351 |
352 | Ctrl+r:エンジンをリセット
353 | Shift+l:ウェイトファイルをロード
354 | Ctrl+Shift+l:白番用ウェイトファイルをロード(訳注:AI対AIの観戦など、白番と黒番であえて違うウェイトを使いたいとき用)
355 | Ctrl+Shift+u:白番用エンジンのアンロード(訳注:上記をキャンセルして、白番でも黒番と同じエンジン・ウェイトを使う)
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 | KataGoを使用しているときは、「View」メニューに「スコアバー(Score bar)」や「勢力(Ownership)」が追加されます。Ownershipをオンにすると、各点の推定勢力が半透明の黒/白の背景で表示されます。さらに、「View」メニューの「碁盤二枚 A (盤面+変化図) (Two boards A (main+PV))」または「碁盤二枚 D (素の碁盤+変化図) (Two boards D (raw+PV))」を選んでおけば、勢力の標準偏差も、サブ碁盤に赤色の背景で表示されます。これはあたかも「KataGoの視線追跡ヒートマップ」のように見えます。
364 |
365 |
366 |
367 | また、黒白の各領域の擬似的な目数(=勢力の合計)が半透明の緑/ピンクの数字で表示されます。この数字は次のように計算されています。
368 |
369 | 空点(および死石)の勢力だけを各領域で合計して表示。ただし白黒の境界線上はこの合計から除外。
370 | 推定スコアとの補正値を碁盤の右上隅に「+n」として表示。理想的にはこの値は、中国ルールなどでは対局終了時の生き石数の差、日本ルールなどでは対局終了時のアゲハマの差に等しい。(訳注:実際には誤差あり)
371 |
372 | したがって、(1)未来の石は考慮されず、(2)中立点(ダメ)も数えられるため、正しい「地の目数」とは言えません。なお、小さなフォントで表示されているのは、広い範囲の非常に小さい勢力を合計した値です。
373 |
374 |
375 |
376 | 悪手は石の上に三角形が表示されます(赤三角 = -5目、紫三角 = -2目)。三角形の濃さはその悪手が実際にとがめられたかを表します。また、灰色の四角形は、直感的な「この一手」を逃した印です。「c」キーを押しながらその石をクリックすると、対応する手に移動します。「<」キーまたは「>」キーを押すと、そのようなミスを一つずつチェックすることができます。
377 |
378 |
379 |
380 | 石のまわりのほのかな赤色は、死活のあいまいさを表します。
381 |
382 |
383 |
384 | 盤面の小さな緑の四角とピンクの×は、最近の手で黒と白の可能性が高まっている箇所を示しています。「/」キーを押し続けると、以前の盤面(「最近の手」の前の盤面)を覗くことができます。
385 |
386 |
387 |
388 | 「v」キー を押し続ければ、目数表示に対応する各領域の境界線と各点の勢力(10=100%, 9=90%, ...)を確認できます。「v」キーを押しながらマウスカーソルをメイン碁盤上または勝率グラフ上にかざすと、その時点の盤面と現在の盤面とを比較できます。
389 |
390 |
391 |
392 | 推定スコアは勝率グラフのオレンジ色の点で表示されます。上で述べた「最近の手」の開始点は、同グラフの「/」タグで表示されています。「c」キーを押しながらマウスカーソルをかざすと、指定した手からの勢力変化を見ることができます。
393 |
394 |
395 |
396 | 想定手順の表示では、必然性の高い手ほど大きい文字で番号が描かれます。また、各手の探索数が碁盤の右辺に表示されます(文字nの高さがn手目の探索数に相当)。碁盤右上の赤い数字は一手目の探索数です。
397 |
398 |
399 | 人間風機能
400 |
401 | KataGo v1.15.0 から、人間風の分析や対局を行う機能が追加されました。これらの機能には、人間風モデルファイルが必要です。
402 |
403 |
404 | 分析:盤上の青と赤の正方形は、初段と五級が直感で好みそうな手を表します。この段級位は、"Edit"メニューの"Preferences"から変更できます。さらに、勝率グラフでは、「どの段級位が好みそうな手か」が青い縦線で表示されます(グラフの上下各半分が黒と白に対応。それぞれ、上から下へ9段、3段、1級、6級、15級)。
405 | 対局:「Match vs AI」モードに段級位設定用のバーが表示されます。なお、人間らしい対局のためには、KataGoの設定を通常の解析用とは変える必要があります。
406 | 自動対局:"Engine"メニューの"HumProfile"から段級位などを設定できます。この設定は、"Tool"メニューの"AI vs. AI"用です。分析には影響しません。
407 | 「もし逃げたら」(実験中):シフトキーを押しながら盤上の石をダブルクリックすると、「その石を取ろう・助けようとしたときの探索木」を表示します(この機能は人間風モデルなしでも動作しますが、良い結果は得られません)。
408 |
409 |
410 | 必要な設定については、KataGoやLizGobanのREADMEファイルを参照ください。
411 |
412 | 勢力分布図
413 |
414 |
415 | 「Ownership」をオンにしていると、画面左下隅に勢力分布図が表示されます(白黒灰橙で塗られた小さな四角形)。
416 | 「x」キーを押し続けると勢力分布図が拡大されます。
417 |
418 |
419 |
420 | x軸は碁盤上の各点で、y軸はその点の勢力です。通常の碁盤なら19×19=361箇所の点があります。これらの各点がx軸上に一列に配置され、その順序は次のように並べかえられています。
421 |
422 | 中央部分(黒山の頂上から白山の頂上までの間)は石の置かれていない空点です。つまり黒白の地がこの部分に対応します。頂上が高く裾がせまい場合は「実利をかせいでいる」、逆に頂上が低く裾が広い場合は「厚みや模様をはっている」と察せられます。
423 | そのすぐ両脇部分は活きている石(石の色と勢力の色が合っている箇所)です。この部分は他よりも少しくっきりした色の黒白で表示されます。
424 | さらに外側は実質的に取られている石(石の色と勢力の色が逆の箇所)です。盤上の白の死に石はこの図の左端に黒色で表示されます。同様に、黒の死に石は右端に白色で表示されます。
425 | 白山の頂上部分に表示される白い縦棒はコミの分です。逆コミの場合は黒山の頂上部分に黒い縦棒が表示されます。
426 |
427 | 黒白の領域を比較しやすいよう、輪郭線の鏡像もあわせて表示されます。また、生死を問わず、石に対応する箇所の上部の余白はオレンジ色で塗られています。この領域は勝率グラフ中の「死活のあいまいさ」に対応します。この領域が広い(=生死不明の石が多い)時には色が赤や紫に変わります。これらの色は盤上で白熱した戦いが生じていることを示唆します。
428 |
429 |
430 |
431 | 透明なオレンジの正方形は、「どちらが何目勝っているか」を「枠の色」と「面積」で表します。この正方形の「上辺の位置」は、黒白の領域の「高さの平均値」に対応します。
432 |
433 |
434 |
435 | 勢力分布図の下の棒グラフは、以下の項目について勢力の差を表します(左から順に):
436 |
437 | 安定した石
438 | 安定していない石
439 | 確定地
440 | あいまいな地
441 | コミ
442 | 総計
443 |
444 | 棒の向きと色はどちらが勝っているか、棒の長さはどれだけ差がついているかです。
445 |
446 |
447 |
448 | さらに、図の中央からのびる緑とピンクの線が、薄く重ねて表示されます。これらはそれぞれ、黒と白のプレイスタイルを示しています。横軸は、「盤上すべての石のあいまいさの総計」が、最近の着手でどれだけ変化したかを表します。縦軸は、「あいまいな地が相手よりどれだけ多いか」が、最近の着手でどれだけ変化したかを表します。これらは、勝率グラフの下に描かれている前述の赤い線と薄い縦線に相当します。
449 |
450 |
451 |
452 |
453 |
454 | Project Home
455 | License (GPL3)
456 |
457 |
458 |
459 |
466 |
467 |
468 |
--------------------------------------------------------------------------------
/src/image_exporter.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 |
3 | function save_blob(blob, filename, callback) {
4 | const reader = new FileReader()
5 | reader.onload = function() {
6 | fs.writeFile(filename, Buffer.from(new Uint8Array(this.result)), callback)
7 | }
8 | reader.readAsArrayBuffer(blob)
9 | }
10 |
11 | function save_dataURL(url, filename, callback) {
12 | const write = ab => fs.writeFile(filename, Buffer.from(ab), callback)
13 | fetch(url).then(res => res.arrayBuffer().then(write))
14 | }
15 |
16 | module.exports = {save_blob, save_dataURL}
17 |
--------------------------------------------------------------------------------
/src/katago_rules.js:
--------------------------------------------------------------------------------
1 | // ref.
2 | // https://github.com/lightvector/KataGo/blob/master/docs/GTP_Extensions.md
3 | // https://www.red-bean.com/sgf/properties.html#RU
4 |
5 | const name_table = [
6 | // [katago_name, preferred_SGF_name, another_SGF_name, yet_another_SGF_name, ...]
7 | ['tromp-taylor'],
8 | ['chinese', 'Chinese'],
9 | ['chinese-ogs'],
10 | ['chinese-kgs'],
11 | ['japanese', 'Japanese', 'jp'],
12 | ['korean', 'Korean'],
13 | ['stone-scoring'],
14 | ['aga', 'AGA'],
15 | ['bga', 'BGA'],
16 | ['new-zealand', 'NZ'],
17 | ['aga-button'],
18 | ]
19 |
20 | const katago_supported_rules = name_table.map(a => a[0])
21 |
22 | const guessed_rule_from_komi = {6.5: 'japanese', 7.5: 'chinese'}
23 |
24 | function katago_rule_from_sgf_rule(sgf_rule, komi) {
25 | if (!sgf_rule) {return guessed_rule_from_komi[komi]}
26 | const normalize = s => s.toLowerCase().replace(/[-_ ]/g, '') // for robustness
27 | const target = normalize(sgf_rule)
28 | const hit = name_table.find(a => a.map(normalize).includes(target))
29 | return hit && hit[0]
30 | }
31 |
32 | function sgf_rule_from_katago_rule(katago_rule) {
33 | if (!katago_rule) {return null}
34 | const hit = name_table.find(a => a[0] === katago_rule)
35 | return hit && (hit[1] || hit[0])
36 | }
37 |
38 | module.exports = {
39 | katago_supported_rules, katago_rule_from_sgf_rule, sgf_rule_from_katago_rule
40 | }
41 |
--------------------------------------------------------------------------------
/src/ladder.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const {has_liberty} = require('./rule.js')
4 |
5 | // "too_short = 4" means that the ladder continuation is disabled at the move 84 in
6 | // https://katagotraining.org/media/games/kata1/versus/kata1-b40c256-s9226359552-d2246551327/2021-12-26/6E0A8DCE646900788EE82DF229D51E18.sgf
7 | const too_short = 4
8 |
9 | // wrap into array for convenience
10 |
11 | let last_ladder_branches = [], last_ladder_prop = null, last_seen_ladder_prop = null
12 | function set_last_ladder_branches(bs) {return last_ladder_branches = bs}
13 |
14 | function ladder_branches(game, stones) {
15 | const orig_ladder = succeeding_ladder(game, stones)
16 | const ladder = orig_ladder ||
17 | try_ladder(last_seen_ladder_prop, game.move_count, stones)
18 | if (!ladder) {return set_last_ladder_branches([])}
19 | last_ladder_prop = ladder.prop
20 | const {moves} = ladder, ladder_game = game.shallow_copy()
21 | ladder_game.delete_future()
22 | ladder_game.trial = true
23 | const pre_ladder_moves = orig_ladder ? [] :
24 | missing_moves(stones, last_seen_ladder_prop)
25 | const extended_moves = [...pre_ladder_moves, ...moves]
26 | extended_moves.forEach(m => ladder_game.push(m))
27 | !orig_ladder && record_hit(extended_moves, [-1, -1]) // hide branch mark
28 | return set_last_ladder_branches([ladder_game])
29 | }
30 |
31 | function missing_moves(cur_stones, prop) {
32 | // .......
33 | // ...X...
34 | // ..XOX..
35 | // ..XO... ? = in_ladder_quadrant
36 | // ...????
37 | // ...????
38 | const {idx, u, v, orig_stones} = prop
39 | const [i0, j0] = idx, [i_sign, j_sign] = idx_plus(u, v)
40 | const front = (k, k0, sign) => (k - k0) * sign >= 0
41 | const in_ladder_quadrant = (i, j) => front(i, i0, i_sign) && front(j, j0, j_sign)
42 | const missing = (h, i, j) => !in_ladder_quadrant(i, j) &&
43 | h && h.stone && !(aa_ref(cur_stones, i, j) || {}).stone
44 | const move_for = (h, i, j) => make_ladder_move(i, j, h.black, h.move_count)
45 | const pick = (h, i, j) => missing(h, i, j) && move_for(h, i, j)
46 | const unsorted_moves = aa_map(orig_stones, pick).flat().filter(truep)
47 | return sort_by_key(unsorted_moves, 'move_count')
48 | }
49 |
50 | function make_ladder_move(i, j, is_black, move_count) {
51 | return {move: idx2move(i, j), is_ladder_move: true, is_black, move_count}
52 | }
53 |
54 | function cancel_ladder_hack(game) {
55 | game.forEach((h, k) => {
56 | if (!h.is_ladder_move) {return}
57 | delete h.is_ladder_move
58 | delete h.ladder_hit
59 | h.move_count = k + 1
60 | h.tag && (h.tag = h.tag.replace(ladder_tag_letter, game.new_tag_maybe(true, null)))
61 | })
62 | }
63 |
64 | function ladder_is_seen() {last_seen_ladder_prop = last_ladder_prop}
65 |
66 | //////////////////////////////////////
67 | // ladder
68 |
69 | function new_ladder(prop) {return {moves: [], prop}}
70 |
71 | function new_prop(idx, is_black, attack_p, u, v, orig_stones) {
72 | return {idx, is_black, attack_p, u, v, orig_stones}
73 | }
74 |
75 | //////////////////////////////////////
76 | // main
77 |
78 | function succeeding_ladder(game, stones) {
79 | const {move_count} = game
80 | const recent_two_moves = game.array_until(move_count).slice(-2)
81 | const valid = (recent_two_moves.length === 2) && recent_two_moves.every(truep) &&
82 | xor(...recent_two_moves.map(m => m.is_black))
83 | if (!valid) {return null}
84 | const args = [last(recent_two_moves), move_count + 1, stones]
85 | return try_to_escape(...args) || try_to_capture(...args)
86 | }
87 |
88 | function try_to_escape(recent_move, move_count, stones) {
89 | return try_to_escape_or_capture(recent_move, move_count, stones,
90 | escape_pattern, escape_liberty_pattern, false)
91 | }
92 | function try_to_capture(recent_move, move_count, stones) {
93 | return try_to_escape_or_capture(recent_move, move_count, stones,
94 | attack_pattern, attack_liberty_pattern, true)
95 | }
96 | function try_to_escape_or_capture(recent_move, move_count, stones,
97 | pattern, liberty_pattern, attack_p) {
98 | const matched = match_pattern(recent_move, pattern, liberty_pattern, stones)
99 | if (!matched) {return null}
100 | const [uv, next_idx] = matched
101 | const prop = new_prop(next_idx, !recent_move.is_black, attack_p, ...uv, stones)
102 | return try_ladder(prop, move_count, stones)
103 | }
104 |
105 | function try_ladder(prop, move_count, stones) {
106 | if (!prop) {return null}
107 | const actual_prop = skip_existing_stones(prop, stones) // for last_seen_ladder_prop
108 | return continue_ladder(new_ladder(prop), actual_prop, move_count, stones)
109 | }
110 | function continue_ladder(ladder, prop, move_count, stones) {
111 | const {idx, is_black, u, v} = prop
112 | const {moves} = ladder
113 | const hit = stopped(idx, is_black, u, v, stones)
114 | if (hit) {return moves.length <= too_short ? null : (record_hit(moves, hit), ladder)}
115 | ladder.moves.push(make_ladder_move(...idx, is_black, move_count))
116 | return continue_ladder(ladder, next_prop(prop), move_count + 1, stones)
117 | }
118 |
119 | function skip_existing_stones(prop, stones) {
120 | const {idx, is_black} = prop
121 | const existing = color_stone_p(aa_ref(stones, ...idx), is_black)
122 | return existing ? skip_existing_stones(next_prop(prop), stones) : prop
123 | }
124 |
125 | function stopped(idx, is_black, u, v, stones) {
126 | const offsets = [idx_plus(u, v), u, v]
127 | const opponent_or_border = d =>
128 | color_stone_or_border(idx_plus(idx, d), !is_black, stones)
129 | return stone_or_border(idx, stones) || offsets.map(opponent_or_border).find(truep)
130 | }
131 |
132 | function record_hit(moves, idx) {
133 | merge(moves[0], {ladder_hit: idx2move(...idx) || 'nowhere', tag: ladder_tag_letter})
134 | }
135 |
136 | function next_prop(prop) {
137 | const {idx, is_black, attack_p, u, v, orig_stones} = prop
138 | const [offset, next_uv] = attack_p ? [idx_minus(v, u), [u, v]] : [v, [v, u]]
139 | const next_idx = idx_plus(idx, offset)
140 | return new_prop(next_idx, !is_black, !attack_p, ...next_uv, orig_stones)
141 | }
142 |
143 | //////////////////////////////////////
144 | // pattern match
145 |
146 | // (in pattern)
147 | // 1, 2: recent two moves (1 can be a past move actually)
148 | // 3: next move
149 | // X, O: same color stone as 1, 2, respectively
150 | // .: empty
151 | // S, x, o: "X or O", "X or .", "O or ."
152 | // ?: don't care
153 |
154 | // (in liberty pattern)
155 | // a, b: at most 1, 2 liberties
156 | // 2, 3: at least 2, 3 liberties
157 | // ?: don't care
158 |
159 | // each position in 3x3 pattern corresponds to p u + q v for (p, q) = ...
160 | // (-1,-1) (-1,0) (-1,1)
161 | // (0,-1) (0,0) (0,1)
162 | // (1,-1) (1,0) (1,1)
163 |
164 | function split_pattern(pat) {
165 | return pat.split("\n").filter(identity).map(s => s.split(""))
166 | }
167 |
168 | const attack_pattern = split_pattern(`
169 | ?S1
170 | X2.
171 | x3.
172 | `)
173 |
174 | const attack_liberty_pattern = split_pattern(`
175 | ??3
176 | 2b?
177 | ???
178 | `)
179 |
180 | const escape_pattern = split_pattern(`
181 | ?SO
182 | S13
183 | ?2?
184 | `)
185 |
186 | const escape_liberty_pattern = split_pattern(`
187 | ??3
188 | ?a?
189 | ?3?
190 | `)
191 |
192 | // (bug) This @ is detected as a ladder move.
193 | // XOO
194 | // OX.
195 | // X@.
196 | // (;SZ[19]KM[6.5];B[dd];W[ed];B[ec];W[dc];B[de];W[eb];B[fd])
197 |
198 | const uv_candidates = seq(8).map(k => {
199 | const [flip_u, flip_v, swap_uv] = [1, 2, 4].map(mask => mask & k)
200 | const sign = flip => flip ? +1 : -1
201 | const u = [sign(flip_u), 0], v = [0, sign(flip_v)]
202 | return swap_uv ? [v, u] : [u, v]
203 | })
204 |
205 | function match_pattern(recent_move, pattern, liberty_pattern, stones) {
206 | const {move, is_black} = recent_move
207 | const recent_move_idx = move2idx(move), recent_move_color = is_black
208 | const shift_pq = idx_mul(-1, get_pattern_offset(pattern, '2'))
209 | const hit_p = uv =>
210 | match_pattern_sub(recent_move_idx, recent_move_color, shift_pq, uv,
211 | pattern, liberty_pattern, stones)
212 | const found_uv = uv_candidates.find(hit_p); if (!found_uv) {return null}
213 | const next_offset = idx_plus(shift_pq, get_pattern_offset(pattern, '3'))
214 | const next_idx = ij_plus_pq(recent_move_idx, next_offset, found_uv)
215 | return [found_uv, next_idx]
216 | }
217 |
218 | function match_pattern_sub(recent_move_idx, recent_move_color, shift_pq, uv,
219 | pattern, liberty_pattern, stones) {
220 | const color2 = recent_move_color, color1 = !color2
221 | const pattern_center_idx = ij_plus_pq(recent_move_idx, shift_pq, uv)
222 | const ij_from_ab = (a, b) => ij_plus_pq(pattern_center_idx, [a - 1, b - 1], uv)
223 | const check = (c, a, b) => {
224 | const ij = ij_from_ab(a, b)
225 | const h = aa_ref(stones, ...ij); if (!h) {return false}
226 | const {stone, black} = h
227 | const [is_color1, is_color2] = [color1, color2].map(col => !xor(black, col))
228 | switch (c) {
229 | case 'X': case '1': return stone && is_color1
230 | case 'O': case '2': return stone && is_color2
231 | case 'x': return !stone || is_color1
232 | case 'o': return !stone || is_color2
233 | case 'S': return stone
234 | case '.': case '3': return !stone
235 | case '?': return true
236 | }
237 | }
238 | const check_liberty = (c, a, b) => {
239 | const ij = ij_from_ab(a, b), has = k => has_liberty(ij, stones, k)
240 | switch (c) {
241 | case 'a': return !has(2)
242 | case 'b': return !has(3)
243 | case '2': return has(2)
244 | case '3': return has(3)
245 | case '?': return true
246 | }
247 | }
248 | const match_p = (pat, chk) => aa_map(pat, chk).flat().every(truep)
249 | return match_p(pattern, check) && match_p(liberty_pattern, check_liberty)
250 | }
251 |
252 | function get_pattern_offset(pattern, letter) {
253 | const scanned = aa_map(pattern, (z, a, b) => (z === letter) && [a, b])
254 | return scanned.flat().find(truep).map(k => k - 1)
255 | }
256 |
257 | function ij_plus_pq(ij, [p, q], [u, v]) {
258 | return idx_plus(ij, idx_plus(idx_mul(p, u), idx_mul(q, v)))
259 | }
260 |
261 | //////////////////////////////////////
262 | // stone utils
263 |
264 | function dame_p(h) {return h && !h.stone}
265 |
266 | function stone_or_border(idx, stones) {
267 | return pred_or_border(idx, stones, h => !h || h.stone)
268 | }
269 | function color_stone_or_border(idx, black, stones) {
270 | return pred_or_border(idx, stones, h => !h || color_stone_p(h, black))
271 | }
272 |
273 | function color_stone_p(h, black) {return h && h.stone && !xor(h.black, black)}
274 |
275 | // internal
276 |
277 | function pred_or_border([i, j], stones, pred) {
278 | const inside = 0 < i && i < stones.length - 1 && 0 < j && j < stones[0].length - 1
279 | return (!inside || pred(aa_ref(stones, i, j))) && [i, j]
280 | }
281 |
282 | function with_temporary_stone(idx, black, stones, f) {
283 | const orig = aa_ref(stones, ...idx)
284 | aa_set(stones, ...idx, {stone:true, black})
285 | const ret = f(stones)
286 | aa_set(stones, ...idx, orig)
287 | return ret
288 | }
289 |
290 | //////////////////////////////////////
291 | // idx utils
292 |
293 | function idx_plus(a, b) {return idx_trans_map(a, b, (p, q) => p + q)}
294 | function idx_mul(coef, a) {return a.map(z => coef * z)}
295 | // function idx_minus(a, b) {return idx_plus(a, idx_mul(-1, b))}
296 | function idx_minus(a, b) {return idx_trans_map(a, b, (p, q) => p - q)}
297 | // function idx_diff(a, b) {return idx_minus(a, b).map(Math.abs)}
298 | // function idx_eq(a, b) {return !idx_diff(a, b).some(identity)}
299 | function idx_eq(a, b) {return idx_trans_map(a, b, (p, q) => p === q).every(identity)}
300 |
301 | // internal
302 |
303 | function idx_trans_map(a, b, f) {return aa_transpose([a, b]).map(ary => f(...ary))}
304 |
305 | //////////////////////////////////////
306 | // exports
307 |
308 | module.exports = {
309 | ladder_branches,
310 | ladder_is_seen,
311 | last_ladder_branches: () => last_ladder_branches,
312 | cancel_ladder_hack,
313 | }
314 |
--------------------------------------------------------------------------------
/src/no_thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kaorahi/lizgoban/77cf4add8d4df13f4e56ffb1d1e6fab2b697291d/src/no_thumbnail.png
--------------------------------------------------------------------------------
/src/option.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const PATH = require('path'), fs = require('fs'), {app} = require('electron')
4 |
5 | const default_path_for = name =>
6 | // suppose three cases:
7 | // 1. npx electron src (obsolete)
8 | // 2. npx electron .
9 | // 3. *.AppImage, *.exe, etc.
10 | PATH.join(app.isPackaged ? app.getAppPath() : __dirname, '..', 'external', name)
11 |
12 | const default_option = {
13 | analyze_interval_centisec: 20,
14 | minimum_suggested_moves: 30,
15 | engine_log_line_length: 500,
16 | sabaki_command: default_path_for('sabaki'),
17 | minimum_auto_restart_millisec: 5000,
18 | autosave_deleted_boards: 5,
19 | autosave_cached_suggestions: 3,
20 | autosave_sec: 300,
21 | wait_for_startup: true,
22 | use_bogoterritory: true,
23 | working_dir: process.env.PORTABLE_EXECUTABLE_DIR || default_path_for('.'),
24 | weight_dir: undefined,
25 | sgf_dir: undefined,
26 | exercise_dir: 'exercise',
27 | max_cached_engines: 3,
28 | max_recent_files: 10,
29 | max_recent_deleted: 20,
30 | face_image_rule: null,
31 | preset: [{label: "leelaz", engine: ["leelaz", "-g", "-w", "network.gz"]}],
32 | record_note_to_SGF: false,
33 | repl: false,
34 | pv_trail_max_suggestions: 0,
35 | amb_gain_recent: 25, // 1 = instantaneous
36 | endstate_blur: 0.5,
37 | random_opening: {
38 | prior_until_movenum: 6,
39 | random_until_movenum: 40,
40 | max_order: 10,
41 | relative_visits: 0.02,
42 | winrate_loss: 1.0,
43 | score_loss: 1.0,
44 | },
45 | humansl_scan_profiles: ['rank_9d', 'rank_3d', 'rank_1k', 'rank_6k', 'rank_15k'],
46 | // humansl_scan_profiles: [
47 | // 'rank_9d', 'rank_6d', 'rank_3d', 'rank_1d',
48 | // 'rank_1k', 'rank_3k', 'rank_6k', 'rank_10k',
49 | // 'rank_15k', 'rank_20k',
50 | // ],
51 | // humansl_scan_profiles: [
52 | // 'rank_9d', 'rank_7d', 'rank_5d',
53 | // 'rank_3d', 'rank_2d', 'rank_1d',
54 | // 'rank_1k', 'rank_2k', 'rank_3k',
55 | // 'rank_5k', 'rank_7k', 'rank_9k',
56 | // 'rank_12k', 'rank_15k', 'rank_20k',
57 | // ],
58 | // humansl_scan_profiles: humansl_rank_profiles,
59 | screenshot_region_command: null, // "slop"
60 | screenshot_capture_command: null, // "maim -f png -g %s | xclip -t image/png -se c"
61 | sgf_from_image_archive_dir: null,
62 | sound_file: {
63 | stone: [
64 | "../sound/put02.mp3",
65 | "../sound/put03.mp3",
66 | "../sound/put04.mp3",
67 | "../sound/put05.mp3",
68 | ],
69 | capture: [
70 | "../sound/capture18.mp3",
71 | "../sound/capture20.mp3",
72 | "../sound/capture58.mp3",
73 | ],
74 | pass: ["../sound/jara62.mp3"],
75 | },
76 | }
77 | const option = {}
78 | let white_preset = []
79 |
80 | const default_config_paths = [
81 | default_path_for('.'), process.env.PORTABLE_EXECUTABLE_DIR,
82 | ]
83 | parse_argv()
84 |
85 | function parse_argv() {
86 | const prepended_args = dir => ['-c', PATH.resolve(dir, 'config.json')]
87 | const argv = [
88 | '-j', JSON.stringify(default_option),
89 | ...default_config_paths.filter(truep).flatMap(prepended_args),
90 | ...process.argv,
91 | ]
92 | argv.forEach((x, i, a) => parse_option(x, a[i + 1]))
93 | }
94 | function parse_option(cur, succ) {
95 | const read_file = path => safely(fs.readFileSync, path) || '{}'
96 | const merge_json = str => verbose_safely(merge_json_unsafe, str)
97 | const merge_json_unsafe = str => merge_with_preset(JSON.parse(str))
98 | const merge_with_preset = orig => {
99 | // accept obsolete key "shortcut" for backward compatibility
100 | orig.shortcut && (orig.preset = [...(orig.preset || []), ...orig.shortcut])
101 | merge(option, orig); expand_preset(option.preset)
102 | update_white_preset(option.preset)
103 | }
104 | const update_white_preset = preset => {
105 | const new_white_preset = (preset || []).map(h => {
106 | const {label, leelaz_command, leelaz_args, engine_for_white} = h
107 | const wfs_for_white = 'wait_for_startup' in h ?
108 | {wait_for_startup_for_white: h.wait_for_startup} : {}
109 | return (leelaz_command && leelaz_args && !engine_for_white) &&
110 | {label, label_for_white: label, ...wfs_for_white,
111 | engine_for_white: [leelaz_command, ...leelaz_args]}
112 | }).filter(truep)
113 | !empty(new_white_preset) && (white_preset = new_white_preset)
114 | }
115 | switch (cur) {
116 | case '-j': merge_json(succ); break
117 | case '-c': merge_json(read_file(succ)); break
118 | }
119 | }
120 |
121 | function option_path(key) {
122 | const path = option[key]; if (!path) {return path}
123 | const ret = PATH.resolve(option.working_dir, path)
124 | key.endsWith('_dir') && safely(fs.mkdirSync, ret)
125 | return ret
126 | }
127 |
128 | function option_expand_path(name) {
129 | const path1 = default_path_for(name)
130 | const path2 = PATH.resolve(option.working_dir, name)
131 | return [path1, path2].find(p => fs.existsSync(p))
132 | }
133 |
134 | function expand_preset(preset) {
135 | preset.forEach(rule => {
136 | // merge rule.option for backward compatibility to 1a88dd40
137 | merge(rule, rule.option || {})
138 | const {engine} = rule; if (!engine) {return}
139 | const [command, ...leelaz_args] = engine
140 | const leelaz_command = resolve_engine_path(command)
141 | const {wait_for_startup} = option
142 | merge(rule, {wait_for_startup, ...rule, leelaz_command, leelaz_args})
143 | })
144 | }
145 |
146 | function resolve_engine_path(given_leelaz_command) {
147 | return PATH.resolve(option.working_dir, given_leelaz_command)
148 | }
149 |
150 | // images
151 | function cook_face_image_rule(endstate_rule, endstate_diff_rule) {
152 | if (!endstate_rule && !endstate_diff_rule) {return null}
153 | const h1 = endstate_rule ? {endstate_rule} : {}
154 | const h2 = endstate_diff_rule ? {endstate_diff_rule} : {}
155 | return {...h1, ...h2}
156 | }
157 | option.face_image_rule = // clean me: ugly overwriting
158 | cook_face_image_rule(option.face_image_rule, option.face_image_diff_rule)
159 | const face_image_paths = Object.values(option.face_image_rule || {}).flat()
160 | .flatMap(([ , b, w]) => [b && [b, b], w && [w, w]]).filter(truep)
161 | const image_paths = [
162 | ['black_stone', 'black.png'],
163 | ['white_stone', 'white.png'],
164 | ['board', 'board.png'],
165 | ...face_image_paths,
166 | ].map(([key, name]) => [key, default_path_for(name)]).filter(([key, path]) => fs.existsSync(path))
167 |
168 | // renderer state
169 | const stored_keys_spec = [
170 | // [key, default value, preference label, preferene shortcut],
171 | ['always_show_coordinates', false, 'Coordinates', 'c'],
172 | ['expand_winrate_bar', false, 'Expanded winrate bar', 'B'],
173 | ['score_bar', true, 'Score bar (KataGo only)', 'C'],
174 | ['show_endstate', true, 'Ownerships (KataGo only)', 'E'],
175 | ['lizzie_style', true, 'Lizzie style', 'l'],
176 | ['let_me_think', false, 'Let me think first', 'M'],
177 | ['auto_overview', true, 'Auto overview', 'v'],
178 | ['random_opening_p', false, 'Random opening', 'r'],
179 | ['sound', true, 'Sound', 's'],
180 | ['humansl_full_scan_p', false, 'Finer dan/kyu scan (KataGo HumanSL)', 'f'],
181 | ['gorule', default_gorule],
182 | ['stone_image_p', true],
183 | ['board_image_p', true],
184 | ['stone_style', '3D'],
185 | ['use_cached_suggest_p', true],
186 | ['komi_for_new_game', leelaz_komi],
187 | ['komi_for_new_handicap_game', handicap_komi],
188 | ['sanity', initial_sanity],
189 | ['persona_code', 'abc'],
190 | ['humansl_stronger_profile', 'rank_1d'],
191 | ['humansl_weaker_profile', 'rank_5k'],
192 | ['humansl_color_enhance', 0.5],
193 | ['humansl_profile_in_match', ''],
194 | ['mcts_max_displayed_nodes', 200],
195 | ]
196 | const preference_spec =
197 | stored_keys_spec.map(([k, , label, shortcut]) => label && [k, label, shortcut]).filter(truep)
198 | const default_for_stored_key = aa2hash(stored_keys_spec.map(([k, v, ]) => [k, v]))
199 | const stored_keys_for_renderer = Object.keys(default_for_stored_key)
200 | function keep_backward_compatibility_of_stone_style(get_stored, set_stored) {
201 | const rename = [['paint', '2D'], ['flat', '2.5D'], ['dome', '3D'], ['face', 'Face']]
202 | const key = 'stone_style', new_name = aa2hash(rename)[get_stored(key)]
203 | new_name && set_stored(key, new_name)
204 | }
205 |
206 | //////////////////////////////////////
207 | // exports
208 |
209 | module.exports = {
210 | option,
211 | option_path,
212 | option_expand_path,
213 | image_paths,
214 | white_preset,
215 | preference_spec,
216 | default_for_stored_key,
217 | stored_keys_for_renderer,
218 | keep_backward_compatibility_of_stone_style,
219 | }
220 |
--------------------------------------------------------------------------------
/src/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "main.js",
3 | "version": "['npx electron src' is obsolete. Use 'npm start' instead.]"
4 | }
5 |
--------------------------------------------------------------------------------
/src/persona_param.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | ///////////////////////////////////////
4 | // constants
5 |
6 | const stone_cases = 3, ownership_cases = 2
7 | const elt_bits = 2, elt_min = -1
8 | const code_radix_bits = 4 // multiple of elt_bits
9 |
10 | const total_cases = stone_cases * ownership_cases
11 | const elt_variations = pow(elt_bits), raw_max = elt_variations - 1
12 | const elt_max = elt_from_raw(raw_max)
13 | const code_radix = pow(code_radix_bits)
14 | const code_len = total_cases * elt_bits / code_radix_bits // must be int
15 |
16 | ///////////////////////////////////////
17 | // public
18 |
19 | function generate_persona_param(code) {
20 | let param, explicitly_given_code
21 | if ((code !== undefined) && !stringp(code)) {return null}
22 | code ? set_code(code) : randomize()
23 |
24 | function get() {return param}
25 | function set(z) {param = z; explicitly_given_code = null}
26 | function get_code() {return true_or(explicitly_given_code, code_for_param(get()))}
27 | function set_code(code) {set(param_for_code(code)); explicitly_given_code = code}
28 | function randomize() {set(random_param())}
29 |
30 | return {
31 | get, set,
32 | get_code, set_code,
33 | randomize,
34 | }
35 | } // persona_param()
36 |
37 | function persona_random_code() {return code_for_param(random_param())}
38 |
39 | function persona_html_for_code(code) {
40 | const board_labels = ["AI's stone", "your stone", "empty grid"]
41 | const ownership_labels = ["score + amb", "score - amb"]
42 | const param = param_for_code(code)
43 | const p_rows = aa_transpose(param)
44 | const format = z => Math.round(z * 5)
45 | // const format = z => z.toFixed(1)
46 | function color_for(elt) {
47 | const rgb = elt >= 0 ? '0,0,255' : '255,0,0'
48 | const alpha = elt >= 0 ? elt / elt_max : elt / elt_min
49 | return `rgba(${rgb},${alpha})`
50 | }
51 | function tag(name, inner, ...attrs) {
52 | const a = attrs.map(([k, v]) => `${k}="${v}"`)
53 | return `<${[name, ...a].join(" ")}>${inner}${name}>`
54 | }
55 | function td(elt) {
56 | const s = format(elt)
57 | const style = `background: ${color_for(elt)};`
58 | return tag("td", s, ["style", style])
59 | }
60 | function th(label) {return tag("th", label)}
61 | function tr(cols) {return tag("tr", cols.join(""))}
62 | const prof = '
' // insert code later for avoiding HTML injection
63 | const header = tr([prof, ...board_labels].map(th))
64 | const rows = p_rows.map((r, k) => tr([th(ownership_labels[k]), ...r.map(td)]))
65 | const table = tag("table", header + rows.join(""))
66 | const sample_for = elt =>
67 | ` `
68 | return `
69 | ${table}
70 |
71 | ${sample_for(elt_max)} care
72 |
73 | ${sample_for(elt_min)} invert (= prefer to lose score)
74 |
75 | `
76 | }
77 |
78 | ///////////////////////////////////////
79 | // private
80 |
81 | function persona_code_valid(code) {
82 | return stringp(code) && code_for_param(param_for_direct_code(code)) === code
83 | }
84 | function code_for_param(param) {
85 | const k = raws_from_param(param).reduce((a, z) => (a << elt_bits) + z)
86 | return to_code(k)
87 | }
88 | function param_for_code(code) {
89 | if (persona_code_valid(code)) {return param_for_direct_code(code)}
90 | const hexes = sha256sum(code).slice(0, total_cases).split('')
91 | const raws = hexes.map(hex => raw_max * parseInt(hex, 16) / 15)
92 | return param_from_raws(raws)
93 | }
94 | function param_for_direct_code(code) {
95 | const elt_radix = elt_variations
96 | const raw_str = to_str(parseInt(code, code_radix), elt_radix, total_cases)
97 | const raws = raw_str.split('').map(c => parseInt(c, elt_radix))
98 | return param_from_raws(raws)
99 | }
100 | function random_param() {
101 | function rand_raw() {return Math.floor(Math.random() * elt_variations)}
102 | const raws = seq(total_cases).map(rand_raw)
103 | return param_from_raws(raws)
104 | }
105 |
106 | // util
107 | function pow(k) {return 1 << k}
108 | function to_str(k, radix, len) {return k.toString(radix).padStart(len, "0")}
109 | function to_code(k) {return to_str(k, code_radix, code_len)}
110 | function elt_from_raw(z) {return z + elt_min} // raw = 0, 1, 2, ...
111 | function raw_from_elt(z) {return z - elt_min}
112 | function param_from_raws(raws) {
113 | const elts = raws.map(elt_from_raw)
114 | function head_of_row(k) {return k * ownership_cases}
115 | function row(k) {return elts.slice(head_of_row(k), head_of_row(k + 1))}
116 | return seq(stone_cases).map(row)
117 | }
118 | function raws_from_param(param) {return param.flat().map(raw_from_elt)}
119 |
120 | ///////////////////////////////////////
121 | // example
122 |
123 | // (() => {
124 | // const p = generate_persona_param()
125 |
126 | // // [ [ 2, 1 ], [ 0, 2 ], [ 0, -1 ] ] e74 (for example)
127 | // console.log(p.get(), p.get_code())
128 | // p.set_code(p.get_code()); console.log(p.get(), p.get_code())
129 |
130 | // // [ [ -1, -1 ], [ -1, -1 ], [ -1, -1 ] ]
131 | // p.set_code("000"); console.log(p.get())
132 |
133 | // // [ [ 2, 2 ], [ 2, 2 ], [ 2, 2 ] ]
134 | // p.set_code("fff"); console.log(p.get())
135 |
136 | // // [ [ -1, 0 ], [ -1, 1 ], [ -1, 2 ] ]
137 | // p.set_code("123"); console.log(p.get())
138 | // })()
139 |
140 | ///////////////////////////////////////
141 | // exports
142 |
143 | module.exports = {
144 | generate_persona_param,
145 | persona_random_code,
146 | persona_html_for_code,
147 | }
148 |
--------------------------------------------------------------------------------
/src/preference_window.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | height: 100%;
3 | margin: 0 0.5em;
4 | padding: 0;
5 | }
6 |
7 | body {
8 | display: flex; flex-direction: column;
9 | color: black; background: white;
10 | }
11 |
12 | .item:hover {
13 | color: black; background: #8f8;
14 | }
15 |
16 | .shortcut {
17 | font-size: small; color:#aaa;
18 | }
19 |
20 | footer {
21 | margin-top: auto; padding: 1em; text-align: center;
22 | }
23 |
--------------------------------------------------------------------------------
/src/preference_window.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | LizGoban Preferences
7 |
8 |
9 |
15 |
16 |
17 |
18 |
19 | Preferences
20 |
21 |
22 |
23 |
24 |
"Human SL net" policy comparison
25 |
26 |
27 | profile
28 |
29 |
30 |
31 |
32 | profile
33 |
34 |
35 |
36 | color enhance
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | Close
51 |
52 | [Esc] or [Ctrl-w]
53 |
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 |
82 |
83 |
84 |
SGF from Image
85 |
Semiautomatic converter from diagram images of the game Go (Weiqi, Baduk) to SGF format
86 |
87 |
88 | copy to clipboard
89 | download SGF
90 |
91 |
92 |
93 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 | Use cursor keys after each click for fine tuning (Shift or Ctrl + cursor keys for other points).
144 | If auto-adjustment fails in usage , try another style in usage2 .
145 | Shift+drag to magnify a region.
146 | To correct tilt/perspective images, shift+click the top right corner and other three corners of the board counterclockwise before Step 1. revert
147 |
148 |
149 |
150 | parameter tuning...
151 |
152 |
153 |
154 |
155 |
156 |
157 | d_rgb =
158 |
159 |
160 |
161 |
162 |
163 |
164 | This page is part of LizGoban project.
165 | License (GPL3)
166 |
167 |
168 |
169 |
170 |
171 |
Parameters revert to default
172 |
206 |
Use browser bookmarks to record these parameters.
207 |
208 | close
209 |
210 |
211 |
212 |
213 |
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
--------------------------------------------------------------------------------