├── jigo.png ├── ci └── extractInfo.js ├── .github └── workflows │ └── create-release.yml ├── LICENSE ├── package.json ├── README.md ├── .gitignore └── src └── main.js /jigo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yishn/KataJigo/HEAD/jigo.png -------------------------------------------------------------------------------- /ci/extractInfo.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const {version} = require('../package.json') 3 | 4 | function printSetOutputs(outputs) { 5 | for (let [name, value] of Object.entries(outputs)) { 6 | console.log(`::set-output name=${name}::${value}`) 7 | } 8 | } 9 | 10 | printSetOutputs({ 11 | version, 12 | tag: (process.env.GITHUB_REF || '').replace('refs/tags/', ''), 13 | ci: path.resolve(process.cwd(), './ci') 14 | }) 15 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | create-release: 10 | runs-on: windows-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: 12.x 17 | - uses: actions/setup-go@v1 18 | with: 19 | go-version: 1.13.x 20 | - name: Extract info 21 | id: info 22 | run: | 23 | node ./ci/extractInfo.js 24 | env: 25 | GITHUB_REF: ${{ github.ref }} 26 | - name: Create & upload artifact 27 | run: | 28 | npm install 29 | npm run dist:all 30 | go get -u github.com/tcnksm/ghr 31 | ./ci/bin/ghr -n "KataJigo v${{ steps.info.outputs.version }}" -draft -replace ${{ steps.info.outputs.tag }} ./dist 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | GOPATH: ${{ steps.info.outputs.ci }} 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yichuan Shen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "katajigo", 3 | "version": "1.0.2", 4 | "description": "", 5 | "bin": "src/main.js", 6 | "main": "src/main.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "format": "prettier \"./**/*.{js,md}\" --write", 10 | "format-watch": "onchange \"./**/*.{js,md}\" -- prettier --write {{changed}}", 11 | "build": "pkg . --targets node12 --out-path ./bin", 12 | "build:win": "pkg . --targets node12-windows-x64 --out-path ./bin/katajigo-win", 13 | "build:mac": "pkg . --targets node12-macos-x64 --out-path ./bin/katajigo-mac", 14 | "build:linux": "pkg . --targets node12-linux-x64 --out-path ./bin/katajigo-linux", 15 | "dist:win": "npm run build:win && mkdirp ./dist && cross-zip ./bin/katajigo-win ./dist/katajigo-win.zip", 16 | "dist:mac": "npm run build:mac && mkdirp ./dist && cross-zip ./bin/katajigo-mac ./dist/katajigo-mac.zip", 17 | "dist:linux": "npm run build:linux && mkdirp ./dist && cross-zip ./bin/katajigo-linux ./dist/katajigo-linux.zip", 18 | "dist:all": "npm run dist:win && npm run dist:mac && npm run dist:linux" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/yishn/KataJigo.git" 23 | }, 24 | "author": "Yichuan Shen", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/yishn/KataJigo/issues" 28 | }, 29 | "homepage": "https://github.com/yishn/KataJigo#readme", 30 | "prettier": { 31 | "semi": false, 32 | "singleQuote": true, 33 | "bracketSpacing": false, 34 | "proseWrap": "always" 35 | }, 36 | "dependencies": { 37 | "@sabaki/gtp": "^3.0.0" 38 | }, 39 | "devDependencies": { 40 | "cross-zip-cli": "^1.0.0", 41 | "mkdirp": "^1.0.3", 42 | "onchange": "^6.1.0", 43 | "pkg": "^4.4.4", 44 | "prettier": "1.19.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KataJigo 2 | 3 | Play against a KataGo that strives for a Jigo instead of opponent destruction. 4 | 5 | Playing against [KataGo](https://github.com/lightvector/KataGo) can be 6 | particularly frustrating. Even before midgame starts, KataGo may have 7 | accumulated an overwhelming lead in points in the best case, or the game could 8 | have turned into a merciless, bloody slaughter of your groups in the worst case, 9 | leading to your inevitable demise, leaving you sad and empty inside. 10 | 11 | KataJigo is an experiment. It will let KataGo always aim for a half point win, 12 | or a draw if playing with an integer komi, slipping into the role of a capable 13 | teacher who's looking down from far above, playing a teaching game (in theory, 14 | at least). 15 | 16 | Jigo game of GnuGo (B) against KataJigo (W) with 7 point komi 21 | 22 | Jigo game of GnuGo (B) against KataJigo (W) with 7 point komi 23 | 24 | ## Installation 25 | 26 | 1. Download both [KataGo](https://github.com/lightvector/KataGo) and 27 | [KataJigo](https://github.com/yishn/KataJigo/releases/latest). 28 | 2. Install and set up KataGo according to the instructions. 29 | 3. In the same folder as KataGo, drop in the executable of KataJigo. 30 | 4. Now, you can use `katajigo` as a drop-in replacement of `katago`. 31 | 32 | If you already set up KataGo in Sabaki, all you need to do is replace the path 33 | to KataGo with the path to KataJigo in the same directory. 34 | 35 | ## How does this work? 36 | 37 | Using the analysis feature of KataGo, we'll consider all the moves with 38 | non-negative `scoreLead` and a `winrate` that is better than 50%. Out of those 39 | moves we'll pick the move with the lowest `scoreLead` and the highest `winrate`. 40 | 41 | ## Building 42 | 43 | Make sure you have Node.js installed. First, clone the repository and install 44 | all dependencies with npm: 45 | 46 | ``` 47 | $ git clone https://github.com/yishn/KataJigo 48 | $ cd KataJigo 49 | $ npm install 50 | ``` 51 | 52 | Run the following command to create an executable in the `./bin` directory: 53 | 54 | ``` 55 | $ npm run build 56 | ``` 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | dist/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # Next.js build output 82 | .next 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {dirname, join} = require('path') 4 | const {Controller, Engine} = require('@sabaki/gtp') 5 | const {version} = require('../package.json') 6 | 7 | function parseAnalysis(line) { 8 | return line 9 | .split(/\s*info\s+/) 10 | .slice(1) 11 | .map(x => x.trim().replace(/ownership\s+(\d+(\.\d+)?\s+)+/g, '')) 12 | .map(x => { 13 | let matchPV = x.match(/(pass|[A-Za-z]\d+)(\s+(pass|[A-Za-z]\d+))*$/) 14 | if (matchPV == null) return null 15 | 16 | let passIndex = matchPV[0].indexOf('pass') 17 | if (passIndex < 0) passIndex = Infinity 18 | 19 | return [ 20 | x 21 | .slice(0, matchPV.index) 22 | .trim() 23 | .split(/\s+/) 24 | .slice(0, -1), 25 | matchPV[0] 26 | .slice(0, passIndex) 27 | .split(/\s+/) 28 | .filter(x => x.length >= 2) 29 | ] 30 | }) 31 | .filter(x => x != null) 32 | .map(([tokens, pv]) => { 33 | let keys = tokens.filter((_, i) => i % 2 === 0) 34 | let values = tokens.filter((_, i) => i % 2 === 1) 35 | 36 | keys.push('pv') 37 | values.push(pv) 38 | 39 | return keys.reduce((acc, x, i) => ((acc[x] = values[i]), acc), {}) 40 | }) 41 | .filter(({move}) => move.match(/^[A-Za-z]\d+$/)) 42 | .map(result => ({ 43 | ...result, 44 | winrate: +result.winrate, 45 | scoreLead: +result.scoreLead 46 | })) 47 | } 48 | 49 | function stringifyAnalysis(analysis) { 50 | return analysis 51 | .map( 52 | entry => 53 | `info ${Object.entries(entry) 54 | .map( 55 | ([key, value]) => 56 | `${key} ${Array.isArray(value) ? value.join(' ') : value}` 57 | ) 58 | .join(' ')}` 59 | ) 60 | .join(' ') 61 | } 62 | 63 | async function main() { 64 | let args = process.argv.slice(2) 65 | let gtpMode = args[0] === 'gtp' 66 | let katagoPath = join(dirname(process.execPath), 'katago') 67 | 68 | let controller = new Controller(katagoPath, args) 69 | let engine = new Engine('KataJigo', version) 70 | 71 | controller.on('started', () => { 72 | if (!gtpMode) { 73 | controller.process.stdout.on('data', chunk => { 74 | process.stdout.write(chunk) 75 | }) 76 | 77 | process.stdin.on('data', chunk => { 78 | controller.process.stdin.write(chunk) 79 | }) 80 | } 81 | }) 82 | 83 | controller.on('stopped', () => { 84 | process.exit() 85 | }) 86 | 87 | controller.on('stderr', ({content}) => { 88 | process.stderr.write(content + '\n') 89 | }) 90 | 91 | async function genmoveAnalyze(args, subscriber = () => {}) { 92 | let lastAnalysis = null 93 | let originalMove = null 94 | let foundMove = null 95 | let response = await controller.sendCommand( 96 | { 97 | name: 'kata-genmove_analyze', 98 | args 99 | }, 100 | evt => { 101 | if (evt.line.startsWith('info ')) { 102 | lastAnalysis = parseAnalysis(evt.line) 103 | } else if (evt.line.startsWith('play ') && lastAnalysis != null) { 104 | originalMove = evt.line.slice('play '.length).trim() 105 | 106 | lastAnalysis = lastAnalysis.filter( 107 | // Ensure we're still winning 108 | variation => variation.winrate >= 0.5 && variation.scoreLead >= 0 109 | ) 110 | 111 | let minScoreLead = Math.min( 112 | ...lastAnalysis.map(variation => variation.scoreLead) 113 | ) 114 | 115 | lastAnalysis = lastAnalysis.filter( 116 | variation => variation.scoreLead === minScoreLead 117 | ) 118 | 119 | let maxWinrate = Math.max( 120 | ...lastAnalysis.map(variation => variation.winrate) 121 | ) 122 | 123 | lastAnalysis = lastAnalysis.filter( 124 | variation => variation.winrate === maxWinrate 125 | ) 126 | 127 | if (lastAnalysis.length > 0) { 128 | let variation = lastAnalysis[0] 129 | 130 | for (let [key, value] of Object.entries(variation)) { 131 | console.error( 132 | `${key}: ${Array.isArray(value) ? value.join(' ') : value}` 133 | ) 134 | } 135 | 136 | foundMove = variation.move 137 | evt.line = `play ${variation.move}` 138 | } 139 | } 140 | 141 | if (originalMove != null && foundMove != null) { 142 | evt.response.content = evt.response.content.replace( 143 | `play ${originalMove}`, 144 | `play ${foundMove}` 145 | ) 146 | } 147 | 148 | subscriber(evt) 149 | } 150 | ) 151 | 152 | if (!response.error && foundMove != null) { 153 | await controller.sendCommand({name: 'undo'}) 154 | await controller.sendCommand({name: 'play', args: [args[0], foundMove]}) 155 | } 156 | 157 | return response 158 | } 159 | 160 | engine.on('command-received', ({command}) => { 161 | controller.process.stdin.write('\n') 162 | 163 | if ( 164 | !['name', 'version', 'genmove', 'lz-genmove_analyze'].includes( 165 | command.name 166 | ) 167 | ) { 168 | engine.command(command.name, async (command, out) => { 169 | let firstWrite = true 170 | let subscriber = ({response, end, line}) => { 171 | if (!response.error && !end) { 172 | if (!firstWrite) out.write('\n') 173 | out.write(firstWrite ? response.content : line) 174 | firstWrite = false 175 | } 176 | } 177 | 178 | let response = 179 | command.name === 'kata-genmove_analyze' 180 | ? await genmoveAnalyze(command.args, subscriber) 181 | : await controller.sendCommand(command, subscriber) 182 | 183 | if (response.error) out.err(response.content) 184 | out.end() 185 | }) 186 | } 187 | }) 188 | 189 | engine.command('genmove', async (command, out) => { 190 | let response = await genmoveAnalyze(command.args.slice(0, 1), ({line}) => { 191 | if (line.startsWith('play ')) { 192 | let move = line.slice('play '.length).trim() 193 | out.send(move) 194 | } 195 | }) 196 | 197 | if (response.error) out.err(response.content) 198 | }) 199 | 200 | engine.command('lz-genmove_analyze', async (command, out) => { 201 | let firstWrite = true 202 | 203 | await genmoveAnalyze(command.args, ({response, line}) => { 204 | if (!firstWrite) out.write('\n') 205 | 206 | if (line.startsWith('info ')) { 207 | let analysis = parseAnalysis(line) 208 | let keys = ['move', 'visits', 'winrate', 'prior', 'lcb', 'order', 'pv'] 209 | 210 | analysis = analysis.map(entry => 211 | keys.reduce((acc, key) => ((acc[key] = entry[key]), acc), {}) 212 | ) 213 | 214 | for (let entry of analysis) { 215 | entry.winrate = Math.round(+entry.winrate * 10000) 216 | entry.prior = Math.round(+entry.prior * 10000) 217 | entry.lcb = Math.round(+entry.lcb * 10000) 218 | } 219 | 220 | out.write(stringifyAnalysis(analysis)) 221 | } else { 222 | out.write(firstWrite ? response.content : line) 223 | } 224 | 225 | firstWrite = false 226 | }) 227 | 228 | out.end() 229 | }) 230 | 231 | controller.start() 232 | if (gtpMode) engine.start() 233 | } 234 | 235 | main().catch(console.error) 236 | --------------------------------------------------------------------------------