From 02d22943c485df8a11c43c10767a885edc4d643b Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 14 Mar 2018 04:20:15 +0900 Subject: [PATCH] Improve othello ai --- package.json | 4 + src/common/othello/ai.ts | 42 ----- src/common/othello/ai/back.ts | 287 +++++++++++++++++++++++++++++++++ src/common/othello/ai/front.ts | 231 ++++++++++++++++++++++++++ src/config.ts | 4 + 5 files changed, 526 insertions(+), 42 deletions(-) delete mode 100644 src/common/othello/ai.ts create mode 100644 src/common/othello/ai/back.ts create mode 100644 src/common/othello/ai/front.ts diff --git a/package.json b/package.json index 37f136f14..d3bf3fce5 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@types/ratelimiter": "2.1.28", "@types/redis": "2.8.5", "@types/request": "2.47.0", + "@types/request-promise-native": "^1.0.14", "@types/rimraf": "2.0.2", "@types/seedrandom": "2.4.27", "@types/serve-favicon": "2.2.30", @@ -78,6 +79,7 @@ "@types/webpack": "3.8.8", "@types/webpack-stream": "3.2.9", "@types/websocket": "0.0.37", + "@types/ws": "^4.0.1", "accesses": "2.5.0", "animejs": "2.2.0", "autosize": "4.0.0", @@ -158,6 +160,7 @@ "reconnecting-websocket": "3.2.2", "redis": "2.8.0", "request": "2.83.0", + "request-promise-native": "^1.0.5", "rimraf": "2.6.2", "rndstr": "1.0.0", "s-age": "1.1.2", @@ -198,6 +201,7 @@ "webpack-cli": "^2.0.8", "webpack-replace-loader": "1.3.0", "websocket": "1.0.25", + "ws": "^5.0.0", "xev": "2.0.0" } } diff --git a/src/common/othello/ai.ts b/src/common/othello/ai.ts deleted file mode 100644 index 3943d04bf..000000000 --- a/src/common/othello/ai.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Othello, { Color } from './core'; - -export function ai(color: Color, othello: Othello) { - //const opponentColor = color == 'black' ? 'white' : 'black'; -/* wip - - function think() { - // 打てる場所を取得 - const ps = othello.canPutSomewhere(color); - - if (ps.length > 0) { // 打てる場所がある場合 - // 角を取得 - const corners = ps.filter(p => - // 左上 - (p[0] == 0 && p[1] == 0) || - // 右上 - (p[0] == (BOARD_SIZE - 1) && p[1] == 0) || - // 右下 - (p[0] == (BOARD_SIZE - 1) && p[1] == (BOARD_SIZE - 1)) || - // 左下 - (p[0] == 0 && p[1] == (BOARD_SIZE - 1)) - ); - - if (corners.length > 0) { // どこかしらの角に打てる場合 - // 打てる角からランダムに選択して打つ - const p = corners[Math.floor(Math.random() * corners.length)]; - othello.set(color, p[0], p[1]); - } else { // 打てる角がない場合 - // 打てる場所からランダムに選択して打つ - const p = ps[Math.floor(Math.random() * ps.length)]; - othello.set(color, p[0], p[1]); - } - - // 相手の打つ場所がない場合続けてAIのターン - if (othello.getPattern(opponentColor).length === 0) { - think(); - } - } - } - - think();*/ -} diff --git a/src/common/othello/ai/back.ts b/src/common/othello/ai/back.ts new file mode 100644 index 000000000..9765b7d4e --- /dev/null +++ b/src/common/othello/ai/back.ts @@ -0,0 +1,287 @@ +/** + * -AI- + * Botのバックエンド(思考を担当) + * + * 対話と思考を同じプロセスで行うと、思考時間が長引いたときにストリームから + * 切断されてしまうので、別々のプロセスで行うようにします + */ + +import Othello, { Color } from '../core'; + +let game; +let form; + +/** + * このBotのユーザーID + */ +let id; + +process.on('message', msg => { + console.log(msg); + + // 親プロセスからデータをもらう + if (msg.type == '_init_') { + game = msg.game; + form = msg.form; + id = msg.id; + } + + // フォームが更新されたとき + if (msg.type == 'update-form') { + form.find(i => i.id == msg.body.id).value = msg.body.value; + } + + // ゲームが始まったとき + if (msg.type == 'started') { + onGameStarted(msg.body); + + //#region TLに投稿する + const game = msg.body; + const url = `https://misskey.xyz/othello/${game.id}`; + const user = game.user1_id == id ? game.user2 : game.user1; + const isSettai = form[0].value === 0; + const text = isSettai + ? `?[${user.name}](https://misskey.xyz/${user.username})さんの接待を始めました!` + : `対局を?[${user.name}](https://misskey.xyz/${user.username})さんと始めました! (強さ${form[0].value})`; + process.send({ + type: 'tl', + text: `${text}\n→[観戦する](${url})` + }); + //#endregion + } + + // ゲームが終了したとき + if (msg.type == 'ended') { + // ストリームから切断 + process.send({ + type: 'close' + }); + + //#region TLに投稿する + const url = `https://misskey.xyz/othello/${msg.body.game.id}`; + const user = game.user1_id == id ? game.user2 : game.user1; + const isSettai = form[0].value === 0; + const text = isSettai + ? msg.body.winner_id === null + ? `?[${user.name}](https://misskey.xyz/${user.username})さんに接待で引き分けました...` + : msg.body.winner_id == id + ? `?[${user.name}](https://misskey.xyz/${user.username})さんに接待で勝ってしまいました...` + : `?[${user.name}](https://misskey.xyz/${user.username})さんに接待で負けてあげました♪` + : msg.body.winner_id === null + ? `?[${user.name}](https://misskey.xyz/${user.username})さんと引き分けました~ (強さ${form[0].value})` + : msg.body.winner_id == id + ? `?[${user.name}](https://misskey.xyz/${user.username})さんに勝ちました♪ (強さ${form[0].value})` + : `?[${user.name}](https://misskey.xyz/${user.username})さんに負けました... (強さ${form[0].value})`; + process.send({ + type: 'tl', + text: `${text}\n→[結果を見る](${url})` + }); + //#endregion + } + + // 打たれたとき + if (msg.type == 'set') { + onSet(msg.body); + } +}); + +let o: Othello; +let botColor: Color; + +// 各マスの強さ +let cellStrongs; + +/** + * ゲーム開始時 + * @param g ゲーム情報 + */ +function onGameStarted(g) { + game = g; + + // オセロエンジン初期化 + o = new Othello(game.settings.map, { + isLlotheo: game.settings.is_llotheo, + canPutEverywhere: game.settings.can_put_everywhere, + loopedBoard: game.settings.looped_board + }); + + // 各マスの価値を計算しておく + cellStrongs = o.map.map((pix, i) => { + if (pix == 'null') return 0; + const [x, y] = o.transformPosToXy(i); + let count = 0; + const get = (x, y) => { + if (x < 0 || y < 0 || x >= o.mapWidth || y >= o.mapHeight) return 'null'; + return o.mapDataGet(o.transformXyToPos(x, y)); + }; + + if (get(x , y - 1) == 'null') count++; + if (get(x + 1, y - 1) == 'null') count++; + if (get(x + 1, y ) == 'null') count++; + if (get(x + 1, y + 1) == 'null') count++; + if (get(x , y + 1) == 'null') count++; + if (get(x - 1, y + 1) == 'null') count++; + if (get(x - 1, y ) == 'null') count++; + if (get(x - 1, y - 1) == 'null') count++; + //return Math.pow(count, 3); + return count >= 5 ? 1 : 0; + }); + + botColor = game.user1_id == id && game.black == 1 || game.user2_id == id && game.black == 2; + + if (botColor) { + think(); + } +} + +function onSet(x) { + o.put(x.color, x.pos, true); + + if (x.next === botColor) { + think(); + } +} + +function think() { + console.log('Thinking...'); + + const isSettai = form[0].value === 0; + + // 接待モードのときは、全力(5手先読みくらい)で負けるようにする + const maxDepth = isSettai ? 5 : form[0].value; + + const db = {}; + + /** + * αβ法での探索 + */ + const dive = (o: Othello, pos: number, alpha = -Infinity, beta = Infinity, depth = 0): number => { + // 試し打ち + const undo = o.put(o.turn, pos, true); + + const key = o.board.toString(); + let cache = db[key]; + if (cache) { + if (alpha >= cache.upper) { + o.undo(undo); + return cache.upper; + } + if (beta <= cache.lower) { + o.undo(undo); + return cache.lower; + } + alpha = Math.max(alpha, cache.lower); + beta = Math.min(beta, cache.upper); + } else { + cache = { + upper: Infinity, + lower: -Infinity + }; + } + + const isBotTurn = o.turn === botColor; + + // 勝った + if (o.turn === null) { + const winner = o.winner; + + // 勝つことによる基本スコア + const base = 10000; + + let score; + + if (game.settings.is_llotheo) { + // 勝ちは勝ちでも、より自分の石を少なくした方が美しい勝ちだと判定する + score = o.winner ? base - (o.blackCount * 100) : base - (o.whiteCount * 100); + } else { + // 勝ちは勝ちでも、より相手の石を少なくした方が美しい勝ちだと判定する + score = o.winner ? base + (o.blackCount * 100) : base + (o.whiteCount * 100); + } + + // 巻き戻し + o.undo(undo); + + // 接待なら自分が負けた方が高スコア + return isSettai + ? winner !== botColor ? score : -score + : winner === botColor ? score : -score; + } + + if (depth === maxDepth) { + let score = o.canPutSomewhere(botColor).length; + + cellStrongs.forEach((s, i) => { + // 係数 + const coefficient = 30; + s = s * coefficient; + + const stone = o.board[i]; + if (stone === botColor) { + // TODO: 価値のあるマスに設置されている自分の石に縦か横に接するマスは価値があると判断する + score += s; + } else if (stone !== null) { + score -= s; + } + }); + + // 巻き戻し + o.undo(undo); + + // ロセオならスコアを反転 + if (game.settings.is_llotheo) score = -score; + + // 接待ならスコアを反転 + if (isSettai) score = -score; + + return score; + } else { + const cans = o.canPutSomewhere(o.turn); + + let value = isBotTurn ? -Infinity : Infinity; + let a = alpha; + let b = beta; + + // 次のターンのプレイヤーにとって最も良い手を取得 + for (const p of cans) { + if (isBotTurn) { + const score = dive(o, p, a, beta, depth + 1); + value = Math.max(value, score); + a = Math.max(a, value); + if (value >= beta) break; + } else { + const score = dive(o, p, alpha, b, depth + 1); + value = Math.min(value, score); + b = Math.min(b, value); + if (value <= alpha) break; + } + } + + // 巻き戻し + o.undo(undo); + + if (value <= alpha) { + cache.upper = value; + } else if (value >= beta) { + cache.lower = value; + } else { + cache.upper = value; + cache.lower = value; + } + + db[key] = cache; + + return value; + } + }; + + const cans = o.canPutSomewhere(botColor); + const scores = cans.map(p => dive(o, p)); + const pos = cans[scores.indexOf(Math.max(...scores))]; + + console.log('Thinked:', pos); + + process.send({ + type: 'put', + pos + }); +} diff --git a/src/common/othello/ai/front.ts b/src/common/othello/ai/front.ts new file mode 100644 index 000000000..99cf1a712 --- /dev/null +++ b/src/common/othello/ai/front.ts @@ -0,0 +1,231 @@ +/** + * -AI- + * Botのフロントエンド(ストリームとの対話を担当) + * + * 対話と思考を同じプロセスで行うと、思考時間が長引いたときにストリームから + * 切断されてしまうので、別々のプロセスで行うようにします + */ + +import * as childProcess from 'child_process'; +import * as WebSocket from 'ws'; +import * as request from 'request-promise-native'; +import conf from '../../../conf'; + +// 設定 //////////////////////////////////////////////////////// + +/** + * BotアカウントのAPIキー + */ +const i = conf.othello_ai.i; + +/** + * BotアカウントのユーザーID + */ +const id = conf.othello_ai.id; + +//////////////////////////////////////////////////////////////// + +/** + * ホームストリーム + */ +const homeStream = new WebSocket(`wss://api.misskey.xyz/?i=${i}`); + +homeStream.on('open', () => { + console.log('home stream opened'); +}); + +homeStream.on('close', () => { + console.log('home stream closed'); +}); + +homeStream.on('message', message => { + const msg = JSON.parse(message.toString()); + + // タイムライン上でなんか言われたまたは返信されたとき + if (msg.type == 'mention' || msg.type == 'reply') { + const post = msg.body; + + // リアクションする + request.post('https://api.misskey.xyz/posts/reactions/create', { + json: { i, + post_id: post.id, + reaction: 'love' + } + }); + + if (post.text) { + if (post.text.indexOf('オセロ') > -1) { + request.post('https://api.misskey.xyz/posts/create', { + json: { i, + reply_id: post.id, + text: '良いですよ~' + } + }); + + invite(post.user_id); + } + } + } + + // メッセージでなんか言われたとき + if (msg.type == 'messaging_message') { + const message = msg.body; + if (message.text) { + if (message.text.indexOf('オセロ') > -1) { + request.post('https://api.misskey.xyz/messaging/messages/create', { + json: { i, + user_id: message.user_id, + text: '良いですよ~' + } + }); + + invite(message.user_id); + } + } + } +}); + +// ユーザーを対局に誘う +function invite(userId) { + request.post('https://api.misskey.xyz/othello/match', { + json: { i, + user_id: userId + } + }); +} + +/** + * オセロストリーム + */ +const othelloStream = new WebSocket(`wss://api.misskey.xyz/othello?i=${i}`); + +othelloStream.on('open', () => { + console.log('othello stream opened'); +}); + +othelloStream.on('close', () => { + console.log('othello stream closed'); +}); + +othelloStream.on('message', message => { + const msg = JSON.parse(message.toString()); + + // 招待されたとき + if (msg.type == 'invited') { + onInviteMe(msg.body.parent); + } + + // マッチしたとき + if (msg.type == 'matched') { + gameStart(msg.body); + } +}); + +/** + * ゲーム開始 + * @param game ゲーム情報 + */ +function gameStart(game) { + // ゲームストリームに接続 + const gw = new WebSocket(`wss://api.misskey.xyz/othello-game?i=${i}&game=${game.id}`); + + gw.on('open', () => { + console.log('othello game stream opened'); + + // フォーム + const form = [{ + id: 'strength', + type: 'radio', + label: '強さ', + value: 2, + items: [{ + label: '接待', + value: 0 + }, { + label: '弱', + value: 1 + }, { + label: '中', + value: 2 + }, { + label: '強', + value: 3 + }, { + label: '最強', + value: 5 + }] + }]; + + //#region バックエンドプロセス開始 + const ai = childProcess.fork(__dirname + '/back.js'); + + // バックエンドプロセスに情報を渡す + ai.send({ + type: '_init_', + game, + form, + id + }); + + ai.on('message', msg => { + if (msg.type == 'put') { + gw.send(JSON.stringify({ + type: 'set', + pos: msg.pos + })); + } else if (msg.type == 'tl') { + request.post('https://api.misskey.xyz/posts/create', { + json: { i, + text: msg.text + } + }); + } else if (msg.type == 'close') { + gw.close(); + } + }); + + // ゲームストリームから情報が流れてきたらそのままバックエンドプロセスに伝える + gw.on('message', message => { + const msg = JSON.parse(message.toString()); + ai.send(msg); + }); + //#endregion + + // フォーム初期化 + setTimeout(() => { + gw.send(JSON.stringify({ + type: 'init-form', + body: form + })); + }, 1000); + + // どんな設定内容の対局でも受け入れる + setTimeout(() => { + gw.send(JSON.stringify({ + type: 'accept' + })); + }, 2000); + }); + + gw.on('close', () => { + console.log('othello game stream closed'); + }); +} + +/** + * オセロの対局に招待されたとき + * @param inviter 誘ってきたユーザー + */ +async function onInviteMe(inviter) { + console.log(`Anybody invited me: @${inviter.username}`); + + // 承認 + const game = await request.post('https://api.misskey.xyz/othello/match', { + json: { + i, + user_id: inviter.id + } + }); + + gameStart(game); +} diff --git a/src/config.ts b/src/config.ts index e327cb0ba..82488cef8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,6 +74,10 @@ type Source = { hook_secret: string; username: string; }; + othello_ai?: { + id: string; + i: string; + }; line_bot?: { channel_secret: string; channel_access_token: string;