diff options
Diffstat (limited to 'src/common')
| -rw-r--r-- | src/common/get-notification-summary.ts | 27 | ||||
| -rw-r--r-- | src/common/get-post-summary.ts | 45 | ||||
| -rw-r--r-- | src/common/get-reaction-emoji.ts | 14 | ||||
| -rw-r--r-- | src/common/othello/ai/back.ts | 376 | ||||
| -rw-r--r-- | src/common/othello/ai/front.ts | 233 | ||||
| -rw-r--r-- | src/common/othello/ai/index.ts | 1 | ||||
| -rw-r--r-- | src/common/othello/core.ts | 340 | ||||
| -rw-r--r-- | src/common/othello/maps.ts | 911 | ||||
| -rw-r--r-- | src/common/text/core/syntax-highlighter.ts | 334 | ||||
| -rw-r--r-- | src/common/text/elements/bold.ts | 14 | ||||
| -rw-r--r-- | src/common/text/elements/code.ts | 17 | ||||
| -rw-r--r-- | src/common/text/elements/emoji.ts | 14 | ||||
| -rw-r--r-- | src/common/text/elements/hashtag.ts | 19 | ||||
| -rw-r--r-- | src/common/text/elements/inline-code.ts | 17 | ||||
| -rw-r--r-- | src/common/text/elements/link.ts | 19 | ||||
| -rw-r--r-- | src/common/text/elements/mention.ts | 17 | ||||
| -rw-r--r-- | src/common/text/elements/quote.ts | 14 | ||||
| -rw-r--r-- | src/common/text/elements/url.ts | 14 | ||||
| -rw-r--r-- | src/common/text/index.ts | 72 | ||||
| -rw-r--r-- | src/common/user/get-acct.ts | 3 | ||||
| -rw-r--r-- | src/common/user/get-summary.ts | 18 | ||||
| -rw-r--r-- | src/common/user/parse-acct.ts | 4 |
22 files changed, 2523 insertions, 0 deletions
diff --git a/src/common/get-notification-summary.ts b/src/common/get-notification-summary.ts new file mode 100644 index 0000000000..03db722c84 --- /dev/null +++ b/src/common/get-notification-summary.ts @@ -0,0 +1,27 @@ +import getPostSummary from './get-post-summary'; +import getReactionEmoji from './get-reaction-emoji'; + +/** + * 通知を表す文字列を取得します。 + * @param notification 通知 + */ +export default function(notification: any): string { + switch (notification.type) { + case 'follow': + return `${notification.user.name}にフォローされました`; + case 'mention': + return `言及されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'reply': + return `返信されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'repost': + return `Repostされました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'quote': + return `引用されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + case 'reaction': + return `リアクションされました:\n${notification.user.name} <${getReactionEmoji(notification.reaction)}>「${getPostSummary(notification.post)}」`; + case 'poll_vote': + return `投票されました:\n${notification.user.name}「${getPostSummary(notification.post)}」`; + default: + return `<不明な通知タイプ: ${notification.type}>`; + } +} diff --git a/src/common/get-post-summary.ts b/src/common/get-post-summary.ts new file mode 100644 index 0000000000..8d0033064f --- /dev/null +++ b/src/common/get-post-summary.ts @@ -0,0 +1,45 @@ +/** + * 投稿を表す文字列を取得します。 + * @param {*} post 投稿 + */ +const summarize = (post: any): string => { + let summary = ''; + + // チャンネル + summary += post.channel ? `${post.channel.title}:` : ''; + + // 本文 + summary += post.text ? post.text : ''; + + // メディアが添付されているとき + if (post.media) { + summary += ` (${post.media.length}つのメディア)`; + } + + // 投票が添付されているとき + if (post.poll) { + summary += ' (投票)'; + } + + // 返信のとき + if (post.replyId) { + if (post.reply) { + summary += ` RE: ${summarize(post.reply)}`; + } else { + summary += ' RE: ...'; + } + } + + // Repostのとき + if (post.repostId) { + if (post.repost) { + summary += ` RP: ${summarize(post.repost)}`; + } else { + summary += ' RP: ...'; + } + } + + return summary.trim(); +}; + +export default summarize; diff --git a/src/common/get-reaction-emoji.ts b/src/common/get-reaction-emoji.ts new file mode 100644 index 0000000000..c661205379 --- /dev/null +++ b/src/common/get-reaction-emoji.ts @@ -0,0 +1,14 @@ +export default function(reaction: string): string { + switch (reaction) { + case 'like': return '👍'; + case 'love': return '❤️'; + case 'laugh': return '😆'; + case 'hmm': return '🤔'; + case 'surprise': return '😮'; + case 'congrats': return '🎉'; + case 'angry': return '💢'; + case 'confused': return '😥'; + case 'pudding': return '🍮'; + default: return ''; + } +} diff --git a/src/common/othello/ai/back.ts b/src/common/othello/ai/back.ts new file mode 100644 index 0000000000..0950adaa9f --- /dev/null +++ b/src/common/othello/ai/back.ts @@ -0,0 +1,376 @@ +/** + * -AI- + * Botのバックエンド(思考を担当) + * + * 対話と思考を同じプロセスで行うと、思考時間が長引いたときにストリームから + * 切断されてしまうので、別々のプロセスで行うようにします + */ + +import * as request from 'request-promise-native'; +import Othello, { Color } from '../core'; +import conf from '../../../conf'; + +let game; +let form; + +/** + * BotアカウントのユーザーID + */ +const id = conf.othello_ai.id; + +/** + * BotアカウントのAPIキー + */ +const i = conf.othello_ai.i; + +let post; + +process.on('message', async msg => { + // 親プロセスからデータをもらう + if (msg.type == '_init_') { + game = msg.game; + form = msg.form; + } + + // フォームが更新されたとき + 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 = `${conf.url}/othello/${game.id}`; + const user = game.user1Id == id ? game.user2 : game.user1; + const isSettai = form[0].value === 0; + const text = isSettai + ? `?[${user.name}](${conf.url}/@${user.username})さんの接待を始めました!` + : `対局を?[${user.name}](${conf.url}/@${user.username})さんと始めました! (強さ${form[0].value})`; + + const res = await request.post(`${conf.api_url}/posts/create`, { + json: { i, + text: `${text}\n→[観戦する](${url})` + } + }); + + post = res.createdPost; + //#endregion + } + + // ゲームが終了したとき + if (msg.type == 'ended') { + // ストリームから切断 + process.send({ + type: 'close' + }); + + //#region TLに投稿する + const user = game.user1Id == id ? game.user2 : game.user1; + const isSettai = form[0].value === 0; + const text = isSettai + ? msg.body.winnerId === null + ? `?[${user.name}](${conf.url}/@${user.username})さんに接待で引き分けました...` + : msg.body.winnerId == id + ? `?[${user.name}](${conf.url}/@${user.username})さんに接待で勝ってしまいました...` + : `?[${user.name}](${conf.url}/@${user.username})さんに接待で負けてあげました♪` + : msg.body.winnerId === null + ? `?[${user.name}](${conf.url}/@${user.username})さんと引き分けました~` + : msg.body.winnerId == id + ? `?[${user.name}](${conf.url}/@${user.username})さんに勝ちました♪` + : `?[${user.name}](${conf.url}/@${user.username})さんに負けました...`; + + await request.post(`${conf.api_url}/posts/create`, { + json: { i, + repostId: post.id, + text: text + } + }); + //#endregion + + process.exit(); + } + + // 打たれたとき + if (msg.type == 'set') { + onSet(msg.body); + } +}); + +let o: Othello; +let botColor: Color; + +// 各マスの強さ +let cellWeights; + +/** + * ゲーム開始時 + * @param g ゲーム情報 + */ +function onGameStarted(g) { + game = g; + + // オセロエンジン初期化 + o = new Othello(game.settings.map, { + isLlotheo: game.settings.isLlotheo, + canPutEverywhere: game.settings.canPutEverywhere, + loopedBoard: game.settings.loopedBoard + }); + + // 各マスの価値を計算しておく + cellWeights = 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 >= 4 ? 1 : 0; + }); + + botColor = game.user1Id == id && game.black == 1 || game.user2Id == id && game.black == 2; + + if (botColor) { + think(); + } +} + +function onSet(x) { + o.put(x.color, x.pos); + + if (x.next === botColor) { + think(); + } +} + +const db = {}; + +function think() { + console.log('Thinking...'); + console.time('think'); + + const isSettai = form[0].value === 0; + + // 接待モードのときは、全力(5手先読みくらい)で負けるようにする + const maxDepth = isSettai ? 5 : form[0].value; + + /** + * Botにとってある局面がどれだけ有利か取得する + */ + function staticEval() { + let score = o.canPutSomewhere(botColor).length; + + cellWeights.forEach((weight, i) => { + // 係数 + const coefficient = 30; + weight = weight * coefficient; + + const stone = o.board[i]; + if (stone === botColor) { + // TODO: 価値のあるマスに設置されている自分の石に縦か横に接するマスは価値があると判断する + score += weight; + } else if (stone !== null) { + score -= weight; + } + }); + + // ロセオならスコアを反転 + if (game.settings.isLlotheo) score = -score; + + // 接待ならスコアを反転 + if (isSettai) score = -score; + + return score; + } + + /** + * αβ法での探索 + */ + const dive = (pos: number, alpha = -Infinity, beta = Infinity, depth = 0): number => { + // 試し打ち + o.put(o.turn, pos); + + const key = o.board.toString(); + let cache = db[key]; + if (cache) { + if (alpha >= cache.upper) { + o.undo(); + return cache.upper; + } + if (beta <= cache.lower) { + o.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.isLlotheo) { + // 勝ちは勝ちでも、より自分の石を少なくした方が美しい勝ちだと判定する + 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(); + + // 接待なら自分が負けた方が高スコア + return isSettai + ? winner !== botColor ? score : -score + : winner === botColor ? score : -score; + } + + if (depth === maxDepth) { + // 静的に評価 + const score = staticEval(); + + // 巻き戻し + o.undo(); + + 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(p, a, beta, depth + 1); + value = Math.max(value, score); + a = Math.max(a, value); + if (value >= beta) break; + } else { + const score = dive(p, alpha, b, depth + 1); + value = Math.min(value, score); + b = Math.min(b, value); + if (value <= alpha) break; + } + } + + // 巻き戻し + o.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 dive2 = (pos: number, alpha = -Infinity, beta = Infinity, depth = 0): number => { + // 試し打ち + o.put(o.turn, pos); + + const isBotTurn = o.turn === botColor; + + // 勝った + if (o.turn === null) { + const winner = o.winner; + + // 勝つことによる基本スコア + const base = 10000; + + let score; + + if (game.settings.isLlotheo) { + // 勝ちは勝ちでも、より自分の石を少なくした方が美しい勝ちだと判定する + 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(); + + // 接待なら自分が負けた方が高スコア + return isSettai + ? winner !== botColor ? score : -score + : winner === botColor ? score : -score; + } + + if (depth === maxDepth) { + // 静的に評価 + const score = staticEval(); + + // 巻き戻し + o.undo(); + + return score; + } else { + const cans = o.canPutSomewhere(o.turn); + + // 次のターンのプレイヤーにとって最も良い手を取得 + for (const p of cans) { + if (isBotTurn) { + alpha = Math.max(alpha, dive2(p, alpha, beta, depth + 1)); + } else { + beta = Math.min(beta, dive2(p, alpha, beta, depth + 1)); + } + if (alpha >= beta) break; + } + + // 巻き戻し + o.undo(); + + return isBotTurn ? alpha : beta; + } + }; + + const cans = o.canPutSomewhere(botColor); + const scores = cans.map(p => dive(p)); + const pos = cans[scores.indexOf(Math.max(...scores))]; + + console.log('Thinked:', pos); + console.timeEnd('think'); + + 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 0000000000..e5496132f6 --- /dev/null +++ b/src/common/othello/ai/front.ts @@ -0,0 +1,233 @@ +/** + * -AI- + * Botのフロントエンド(ストリームとの対話を担当) + * + * 対話と思考を同じプロセスで行うと、思考時間が長引いたときにストリームから + * 切断されてしまうので、別々のプロセスで行うようにします + */ + +import * as childProcess from 'child_process'; +const WebSocket = require('ws'); +import * as ReconnectingWebSocket from 'reconnecting-websocket'; +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 ReconnectingWebSocket(`${conf.ws_url}/?i=${i}`, undefined, { + constructor: WebSocket +}); + +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; + + if (post.userId == id) return; + + // リアクションする + request.post(`${conf.api_url}/posts/reactions/create`, { + json: { i, + postId: post.id, + reaction: 'love' + } + }); + + if (post.text) { + if (post.text.indexOf('オセロ') > -1) { + request.post(`${conf.api_url}/posts/create`, { + json: { i, + replyId: post.id, + text: '良いですよ~' + } + }); + + invite(post.userId); + } + } + } + + // メッセージでなんか言われたとき + if (msg.type == 'messaging_message') { + const message = msg.body; + if (message.text) { + if (message.text.indexOf('オセロ') > -1) { + request.post(`${conf.api_url}/messaging/messages/create`, { + json: { i, + userId: message.userId, + text: '良いですよ~' + } + }); + + invite(message.userId); + } + } + } +}); + +// ユーザーを対局に誘う +function invite(userId) { + request.post(`${conf.api_url}/othello/match`, { + json: { i, + userId: userId + } + }); +} + +/** + * オセロストリーム + */ +const othelloStream = new ReconnectingWebSocket(`${conf.ws_url}/othello?i=${i}`, undefined, { + constructor: WebSocket +}); + +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 ReconnectingWebSocket(`${conf.ws_url}/othello-game?i=${i}&game=${game.id}`, undefined, { + constructor: WebSocket + }); + + 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 + }); + + ai.on('message', msg => { + if (msg.type == 'put') { + gw.send(JSON.stringify({ + type: 'set', + pos: msg.pos + })); + } 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(`Someone invited me: @${inviter.username}`); + + // 承認 + const game = await request.post(`${conf.api_url}/othello/match`, { + json: { + i, + userId: inviter.id + } + }); + + gameStart(game); +} diff --git a/src/common/othello/ai/index.ts b/src/common/othello/ai/index.ts new file mode 100644 index 0000000000..5cd1db82da --- /dev/null +++ b/src/common/othello/ai/index.ts @@ -0,0 +1 @@ +require('./front'); diff --git a/src/common/othello/core.ts b/src/common/othello/core.ts new file mode 100644 index 0000000000..217066d375 --- /dev/null +++ b/src/common/othello/core.ts @@ -0,0 +1,340 @@ +/** + * true ... 黒 + * false ... 白 + */ +export type Color = boolean; +const BLACK = true; +const WHITE = false; + +export type MapPixel = 'null' | 'empty'; + +export type Options = { + isLlotheo: boolean; + canPutEverywhere: boolean; + loopedBoard: boolean; +}; + +export type Undo = { + /** + * 色 + */ + color: Color, + + /** + * どこに打ったか + */ + pos: number; + + /** + * 反転した石の位置の配列 + */ + effects: number[]; + + /** + * ターン + */ + turn: Color; +}; + +/** + * オセロエンジン + */ +export default class Othello { + public map: MapPixel[]; + public mapWidth: number; + public mapHeight: number; + public board: Color[]; + public turn: Color = BLACK; + public opts: Options; + + public prevPos = -1; + public prevColor: Color = null; + + private logs: Undo[] = []; + + /** + * ゲームを初期化します + */ + constructor(map: string[], opts: Options) { + //#region binds + this.put = this.put.bind(this); + //#endregion + + //#region Options + this.opts = opts; + if (this.opts.isLlotheo == null) this.opts.isLlotheo = false; + if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false; + if (this.opts.loopedBoard == null) this.opts.loopedBoard = false; + //#endregion + + //#region Parse map data + this.mapWidth = map[0].length; + this.mapHeight = map.length; + const mapData = map.join(''); + + this.board = mapData.split('').map(d => { + if (d == '-') return null; + if (d == 'b') return BLACK; + if (d == 'w') return WHITE; + return undefined; + }); + + this.map = mapData.split('').map(d => { + if (d == '-' || d == 'b' || d == 'w') return 'empty'; + return 'null'; + }); + //#endregion + + // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある + if (this.canPutSomewhere(BLACK).length == 0) { + if (this.canPutSomewhere(WHITE).length == 0) { + this.turn = null; + } else { + this.turn = WHITE; + } + } + } + + /** + * 黒石の数 + */ + public get blackCount() { + return this.board.filter(x => x === BLACK).length; + } + + /** + * 白石の数 + */ + public get whiteCount() { + return this.board.filter(x => x === WHITE).length; + } + + /** + * 黒石の比率 + */ + public get blackP() { + if (this.blackCount == 0 && this.whiteCount == 0) return 0; + return this.blackCount / (this.blackCount + this.whiteCount); + } + + /** + * 白石の比率 + */ + public get whiteP() { + if (this.blackCount == 0 && this.whiteCount == 0) return 0; + return this.whiteCount / (this.blackCount + this.whiteCount); + } + + public transformPosToXy(pos: number): number[] { + const x = pos % this.mapWidth; + const y = Math.floor(pos / this.mapWidth); + return [x, y]; + } + + public transformXyToPos(x: number, y: number): number { + return x + (y * this.mapWidth); + } + + /** + * 指定のマスに石を打ちます + * @param color 石の色 + * @param pos 位置 + */ + public put(color: Color, pos: number) { + this.prevPos = pos; + this.prevColor = color; + + this.board[pos] = color; + + // 反転させられる石を取得 + const effects = this.effects(color, pos); + + // 反転させる + for (const pos of effects) { + this.board[pos] = color; + } + + const turn = this.turn; + + this.logs.push({ + color, + pos, + effects, + turn + }); + + this.calcTurn(); + } + + private calcTurn() { + // ターン計算 + if (this.canPutSomewhere(!this.prevColor).length > 0) { + this.turn = !this.prevColor; + } else if (this.canPutSomewhere(this.prevColor).length > 0) { + this.turn = this.prevColor; + } else { + this.turn = null; + } + } + + public undo() { + const undo = this.logs.pop(); + this.prevColor = undo.color; + this.prevPos = undo.pos; + this.board[undo.pos] = null; + for (const pos of undo.effects) { + const color = this.board[pos]; + this.board[pos] = !color; + } + this.turn = undo.turn; + } + + /** + * 指定した位置のマップデータのマスを取得します + * @param pos 位置 + */ + public mapDataGet(pos: number): MapPixel { + const [x, y] = this.transformPosToXy(pos); + if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) return 'null'; + return this.map[pos]; + } + + /** + * 打つことができる場所を取得します + */ + public canPutSomewhere(color: Color): number[] { + const result = []; + + this.board.forEach((x, i) => { + if (this.canPut(color, i)) result.push(i); + }); + + return result; + } + + /** + * 指定のマスに石を打つことができるかどうかを取得します + * @param color 自分の色 + * @param pos 位置 + */ + public canPut(color: Color, pos: number): boolean { + // 既に石が置いてある場所には打てない + if (this.board[pos] !== null) return false; + + if (this.opts.canPutEverywhere) { + // 挟んでなくても置けるモード + return this.mapDataGet(pos) == 'empty'; + } else { + // 相手の石を1つでも反転させられるか + return this.effects(color, pos).length !== 0; + } + } + + /** + * 指定のマスに石を置いた時の、反転させられる石を取得します + * @param color 自分の色 + * @param pos 位置 + */ + public effects(color: Color, pos: number): number[] { + const enemyColor = !color; + + // ひっくり返せる石(の位置)リスト + let stones = []; + + const initPos = pos; + + // 走査 + const iterate = (fn: (i: number) => number[]) => { + let i = 1; + const found = []; + + while (true) { + let [x, y] = fn(i); + + // 座標が指し示す位置がボード外に出たとき + if (this.opts.loopedBoard) { + if (x < 0 ) x = this.mapWidth - ((-x) % this.mapWidth); + if (y < 0 ) y = this.mapHeight - ((-y) % this.mapHeight); + if (x >= this.mapWidth ) x = x % this.mapWidth; + if (y >= this.mapHeight) y = y % this.mapHeight; + + // for debug + //if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) { + // console.log(x, y); + //} + + // 一周して自分に帰ってきたら + if (this.transformXyToPos(x, y) == initPos) { + // ↓のコメントアウトを外すと、「現時点で自分の石が隣接していないが、 + // そこに置いたとするとループして最終的に挟んだことになる」というケースを有効化します。(Test4のマップで違いが分かります) + // このケースを有効にした方が良いのか無効にした方が良いのか判断がつかなかったためとりあえず無効としておきます + // (あと無効な方がゲームとしておもしろそうだった) + stones = stones.concat(found); + break; + } + } else { + if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) break; + } + + const pos = this.transformXyToPos(x, y); + + //#region 「配置不能」マスに当たった場合走査終了 + const pixel = this.mapDataGet(pos); + if (pixel == 'null') break; + //#endregion + + // 石取得 + const stone = this.board[pos]; + + // 石が置かれていないマスなら走査終了 + if (stone === null) break; + + // 相手の石なら「ひっくり返せるかもリスト」に入れておく + if (stone === enemyColor) found.push(pos); + + // 自分の石なら「ひっくり返せるかもリスト」を「ひっくり返せるリスト」に入れ、走査終了 + if (stone === color) { + stones = stones.concat(found); + break; + } + + i++; + } + }; + + const [x, y] = this.transformPosToXy(pos); + + iterate(i => [x , y - i]); // 上 + iterate(i => [x + i, y - i]); // 右上 + iterate(i => [x + i, y ]); // 右 + iterate(i => [x + i, y + i]); // 右下 + iterate(i => [x , y + i]); // 下 + iterate(i => [x - i, y + i]); // 左下 + iterate(i => [x - i, y ]); // 左 + iterate(i => [x - i, y - i]); // 左上 + + return stones; + } + + /** + * ゲームが終了したか否か + */ + public get isEnded(): boolean { + return this.turn === null; + } + + /** + * ゲームの勝者 (null = 引き分け) + */ + public get winner(): Color { + if (!this.isEnded) return undefined; + + if (this.blackCount == this.whiteCount) return null; + + if (this.opts.isLlotheo) { + return this.blackCount > this.whiteCount ? WHITE : BLACK; + } else { + return this.blackCount > this.whiteCount ? BLACK : WHITE; + } + } +} diff --git a/src/common/othello/maps.ts b/src/common/othello/maps.ts new file mode 100644 index 0000000000..68e5a446f1 --- /dev/null +++ b/src/common/othello/maps.ts @@ -0,0 +1,911 @@ +/** + * 組み込みマップ定義 + * + * データ値: + * (スペース) ... マス無し + * - ... マス + * b ... 初期配置される黒石 + * w ... 初期配置される白石 + */ + +export type Map = { + name?: string; + category?: string; + author?: string; + data: string[]; +}; + +export const fourfour: Map = { + name: '4x4', + category: '4x4', + data: [ + '----', + '-wb-', + '-bw-', + '----' + ] +}; + +export const sixsix: Map = { + name: '6x6', + category: '6x6', + data: [ + '------', + '------', + '--wb--', + '--bw--', + '------', + '------' + ] +}; + +export const roundedSixsix: Map = { + name: '6x6 rounded', + category: '6x6', + author: 'syuilo', + data: [ + ' ---- ', + '------', + '--wb--', + '--bw--', + '------', + ' ---- ' + ] +}; + +export const roundedSixsix2: Map = { + name: '6x6 rounded 2', + category: '6x6', + author: 'syuilo', + data: [ + ' -- ', + ' ---- ', + '--wb--', + '--bw--', + ' ---- ', + ' -- ' + ] +}; + +export const eighteight: Map = { + name: '8x8', + category: '8x8', + data: [ + '--------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const eighteightH1: Map = { + name: '8x8 handicap 1', + category: '8x8', + data: [ + 'b-------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const eighteightH2: Map = { + name: '8x8 handicap 2', + category: '8x8', + data: [ + 'b-------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '-------b' + ] +}; + +export const eighteightH3: Map = { + name: '8x8 handicap 3', + category: '8x8', + data: [ + 'b------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '-------b' + ] +}; + +export const eighteightH4: Map = { + name: '8x8 handicap 4', + category: '8x8', + data: [ + 'b------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + 'b------b' + ] +}; + +export const eighteightH12: Map = { + name: '8x8 handicap 12', + category: '8x8', + data: [ + 'bb----bb', + 'b------b', + '--------', + '---wb---', + '---bw---', + '--------', + 'b------b', + 'bb----bb' + ] +}; + +export const eighteightH16: Map = { + name: '8x8 handicap 16', + category: '8x8', + data: [ + 'bbb---bb', + 'b------b', + '-------b', + '---wb---', + '---bw---', + 'b-------', + 'b------b', + 'bb---bbb' + ] +}; + +export const eighteightH20: Map = { + name: '8x8 handicap 20', + category: '8x8', + data: [ + 'bbb--bbb', + 'b------b', + 'b------b', + '---wb---', + '---bw---', + 'b------b', + 'b------b', + 'bbb---bb' + ] +}; + +export const eighteightH28: Map = { + name: '8x8 handicap 28', + category: '8x8', + data: [ + 'bbbbbbbb', + 'b------b', + 'b------b', + 'b--wb--b', + 'b--bw--b', + 'b------b', + 'b------b', + 'bbbbbbbb' + ] +}; + +export const roundedEighteight: Map = { + name: '8x8 rounded', + category: '8x8', + author: 'syuilo', + data: [ + ' ------ ', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + ' ------ ' + ] +}; + +export const roundedEighteight2: Map = { + name: '8x8 rounded 2', + category: '8x8', + author: 'syuilo', + data: [ + ' ---- ', + ' ------ ', + '--------', + '---wb---', + '---bw---', + '--------', + ' ------ ', + ' ---- ' + ] +}; + +export const roundedEighteight3: Map = { + name: '8x8 rounded 3', + category: '8x8', + author: 'syuilo', + data: [ + ' -- ', + ' ---- ', + ' ------ ', + '---wb---', + '---bw---', + ' ------ ', + ' ---- ', + ' -- ' + ] +}; + +export const eighteightWithNotch: Map = { + name: '8x8 with notch', + category: '8x8', + author: 'syuilo', + data: [ + '--- ---', + '--------', + '--------', + ' --wb-- ', + ' --bw-- ', + '--------', + '--------', + '--- ---' + ] +}; + +export const eighteightWithSomeHoles: Map = { + name: '8x8 with some holes', + category: '8x8', + author: 'syuilo', + data: [ + '--- ----', + '----- --', + '-- -----', + '---wb---', + '---bw- -', + ' -------', + '--- ----', + '--------' + ] +}; + +export const circle: Map = { + name: 'Circle', + category: '8x8', + author: 'syuilo', + data: [ + ' -- ', + ' ------ ', + ' ------ ', + '---wb---', + '---bw---', + ' ------ ', + ' ------ ', + ' -- ' + ] +}; + +export const smile: Map = { + name: 'Smile', + category: '8x8', + author: 'syuilo', + data: [ + ' ------ ', + '--------', + '-- -- --', + '---wb---', + '-- bw --', + '--- ---', + '--------', + ' ------ ' + ] +}; + +export const window: Map = { + name: 'Window', + category: '8x8', + author: 'syuilo', + data: [ + '--------', + '- -- -', + '- -- -', + '---wb---', + '---bw---', + '- -- -', + '- -- -', + '--------' + ] +}; + +export const reserved: Map = { + name: 'Reserved', + category: '8x8', + author: 'Aya', + data: [ + 'w------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + 'b------w' + ] +}; + +export const x: Map = { + name: 'X', + category: '8x8', + author: 'Aya', + data: [ + 'w------b', + '-w----b-', + '--w--b--', + '---wb---', + '---bw---', + '--b--w--', + '-b----w-', + 'b------w' + ] +}; + +export const parallel: Map = { + name: 'Parallel', + category: '8x8', + author: 'Aya', + data: [ + '--------', + '--------', + '--------', + '---bb---', + '---ww---', + '--------', + '--------', + '--------' + ] +}; + +export const lackOfBlack: Map = { + name: 'Lack of Black', + category: '8x8', + data: [ + '--------', + '--------', + '--------', + '---w----', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const squareParty: Map = { + name: 'Square Party', + category: '8x8', + author: 'syuilo', + data: [ + '--------', + '-wwwbbb-', + '-w-wb-b-', + '-wwwbbb-', + '-bbbwww-', + '-b-bw-w-', + '-bbbwww-', + '--------' + ] +}; + +export const minesweeper: Map = { + name: 'Minesweeper', + category: '8x8', + author: 'syuilo', + data: [ + 'b-b--w-w', + '-w-wb-b-', + 'w-b--w-b', + '-b-wb-w-', + '-w-bw-b-', + 'b-w--b-w', + '-b-bw-w-', + 'w-w--b-b' + ] +}; + +export const tenthtenth: Map = { + name: '10x10', + category: '10x10', + data: [ + '----------', + '----------', + '----------', + '----------', + '----wb----', + '----bw----', + '----------', + '----------', + '----------', + '----------' + ] +}; + +export const hole: Map = { + name: 'The Hole', + category: '10x10', + author: 'syuilo', + data: [ + '----------', + '----------', + '--wb--wb--', + '--bw--bw--', + '---- ----', + '---- ----', + '--wb--wb--', + '--bw--bw--', + '----------', + '----------' + ] +}; + +export const grid: Map = { + name: 'Grid', + category: '10x10', + author: 'syuilo', + data: [ + '----------', + '- - -- - -', + '----------', + '- - -- - -', + '----wb----', + '----bw----', + '- - -- - -', + '----------', + '- - -- - -', + '----------' + ] +}; + +export const cross: Map = { + name: 'Cross', + category: '10x10', + author: 'Aya', + data: [ + ' ---- ', + ' ---- ', + ' ---- ', + '----------', + '----wb----', + '----bw----', + '----------', + ' ---- ', + ' ---- ', + ' ---- ' + ] +}; + +export const charX: Map = { + name: 'Char X', + category: '10x10', + author: 'syuilo', + data: [ + '--- ---', + '---- ----', + '----------', + ' -------- ', + ' --wb-- ', + ' --bw-- ', + ' -------- ', + '----------', + '---- ----', + '--- ---' + ] +}; + +export const charY: Map = { + name: 'Char Y', + category: '10x10', + author: 'syuilo', + data: [ + '--- ---', + '---- ----', + '----------', + ' -------- ', + ' --wb-- ', + ' --bw-- ', + ' ------ ', + ' ------ ', + ' ------ ', + ' ------ ' + ] +}; + +export const walls: Map = { + name: 'Walls', + category: '10x10', + author: 'Aya', + data: [ + ' bbbbbbbb ', + 'w--------w', + 'w--------w', + 'w--------w', + 'w---wb---w', + 'w---bw---w', + 'w--------w', + 'w--------w', + 'w--------w', + ' bbbbbbbb ' + ] +}; + +export const cpu: Map = { + name: 'CPU', + category: '10x10', + author: 'syuilo', + data: [ + ' b b b b ', + 'w--------w', + ' -------- ', + 'w--------w', + ' ---wb--- ', + ' ---bw--- ', + 'w--------w', + ' -------- ', + 'w--------w', + ' b b b b ' + ] +}; + +export const checker: Map = { + name: 'Checker', + category: '10x10', + author: 'Aya', + data: [ + '----------', + '----------', + '----------', + '---wbwb---', + '---bwbw---', + '---wbwb---', + '---bwbw---', + '----------', + '----------', + '----------' + ] +}; + +export const japaneseCurry: Map = { + name: 'Japanese curry', + category: '10x10', + author: 'syuilo', + data: [ + 'w-b-b-b-b-', + '-w-b-b-b-b', + 'w-w-b-b-b-', + '-w-w-b-b-b', + 'w-w-wwb-b-', + '-w-wbb-b-b', + 'w-w-w-b-b-', + '-w-w-w-b-b', + 'w-w-w-w-b-', + '-w-w-w-w-b' + ] +}; + +export const mosaic: Map = { + name: 'Mosaic', + category: '10x10', + author: 'syuilo', + data: [ + '- - - - - ', + ' - - - - -', + '- - - - - ', + ' - w w - -', + '- - b b - ', + ' - w w - -', + '- - b b - ', + ' - - - - -', + '- - - - - ', + ' - - - - -', + ] +}; + +export const arena: Map = { + name: 'Arena', + category: '10x10', + author: 'syuilo', + data: [ + '- - -- - -', + ' - - - - ', + '- ------ -', + ' -------- ', + '- --wb-- -', + '- --bw-- -', + ' -------- ', + '- ------ -', + ' - - - - ', + '- - -- - -' + ] +}; + +export const reactor: Map = { + name: 'Reactor', + category: '10x10', + author: 'syuilo', + data: [ + '-w------b-', + 'b- - - -w', + '- --wb-- -', + '---b w---', + '- b wb w -', + '- w bw b -', + '---w b---', + '- --bw-- -', + 'w- - - -b', + '-b------w-' + ] +}; + +export const sixeight: Map = { + name: '6x8', + category: 'Special', + data: [ + '------', + '------', + '------', + '--wb--', + '--bw--', + '------', + '------', + '------' + ] +}; + +export const spark: Map = { + name: 'Spark', + category: 'Special', + author: 'syuilo', + data: [ + ' - - ', + '----------', + ' -------- ', + ' -------- ', + ' ---wb--- ', + ' ---bw--- ', + ' -------- ', + ' -------- ', + '----------', + ' - - ' + ] +}; + +export const islands: Map = { + name: 'Islands', + category: 'Special', + author: 'syuilo', + data: [ + '-------- ', + '---wb--- ', + '---bw--- ', + '-------- ', + ' - - ', + ' - - ', + ' --------', + ' --------', + ' --------', + ' --------' + ] +}; + +export const galaxy: Map = { + name: 'Galaxy', + category: 'Special', + author: 'syuilo', + data: [ + ' ------ ', + ' --www--- ', + ' ------w--- ', + '---bbb--w---', + '--b---b-w-b-', + '-b--wwb-w-b-', + '-b-w-bww--b-', + '-b-w-b---b--', + '---w--bbb---', + ' ---w------ ', + ' ---www-- ', + ' ------ ' + ] +}; + +export const triangle: Map = { + name: 'Triangle', + category: 'Special', + author: 'syuilo', + data: [ + ' -- ', + ' -- ', + ' ---- ', + ' ---- ', + ' --wb-- ', + ' --bw-- ', + ' -------- ', + ' -------- ', + '----------', + '----------' + ] +}; + +export const iphonex: Map = { + name: 'iPhone X', + category: 'Special', + author: 'syuilo', + data: [ + ' -- -- ', + '--------', + '--------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------', + '--------', + ' ------ ' + ] +}; + +export const dealWithIt: Map = { + name: 'Deal with it!', + category: 'Special', + author: 'syuilo', + data: [ + '------------', + '--w-b-------', + ' --b-w------', + ' --w-b---- ', + ' ------- ' + ] +}; + +export const experiment: Map = { + name: 'Let\'s experiment', + category: 'Special', + author: 'syuilo', + data: [ + ' ------------ ', + '------wb------', + '------bw------', + '--------------', + ' - - ', + '------ ------', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'wwwwww bbbbbb' + ] +}; + +export const bigBoard: Map = { + name: 'Big board', + category: 'Special', + data: [ + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '-------wb-------', + '-------bw-------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------' + ] +}; + +export const twoBoard: Map = { + name: 'Two board', + category: 'Special', + author: 'Aya', + data: [ + '-------- --------', + '-------- --------', + '-------- --------', + '---wb--- ---wb---', + '---bw--- ---bw---', + '-------- --------', + '-------- --------', + '-------- --------' + ] +}; + +export const test1: Map = { + name: 'Test1', + category: 'Test', + data: [ + '--------', + '---wb---', + '---bw---', + '--------' + ] +}; + +export const test2: Map = { + name: 'Test2', + category: 'Test', + data: [ + '------', + '------', + '-b--w-', + '-w--b-', + '-w--b-' + ] +}; + +export const test3: Map = { + name: 'Test3', + category: 'Test', + data: [ + '-w-', + '--w', + 'w--', + '-w-', + '--w', + 'w--', + '-w-', + '--w', + 'w--', + '-w-', + '---', + 'b--', + ] +}; + +export const test4: Map = { + name: 'Test4', + category: 'Test', + data: [ + '-w--b-', + '-w--b-', + '------', + '-w--b-', + '-w--b-' + ] +}; + +// https://misskey.xyz/othello/5aaabf7fe126e10b5216ea09 64 +export const test5: Map = { + name: 'Test5', + category: 'Test', + data: [ + '--wwwwww--', + '--wwwbwwww', + '-bwwbwbwww', + '-bwwwbwbww', + '-bwwbwbwbw', + '-bwbwbwb-w', + 'bwbwwbbb-w', + 'w-wbbbbb--', + '--w-b-w---', + '----------' + ] +}; diff --git a/src/common/text/core/syntax-highlighter.ts b/src/common/text/core/syntax-highlighter.ts new file mode 100644 index 0000000000..c0396b1fc6 --- /dev/null +++ b/src/common/text/core/syntax-highlighter.ts @@ -0,0 +1,334 @@ +function escape(text) { + return text + .replace(/>/g, '>') + .replace(/</g, '<'); +} + +// 文字数が多い順にソートします +// そうしないと、「function」という文字列が与えられたときに「func」が先にマッチしてしまう可能性があるためです +const _keywords = [ + 'true', + 'false', + 'null', + 'nil', + 'undefined', + 'void', + 'var', + 'const', + 'let', + 'mut', + 'dim', + 'if', + 'then', + 'else', + 'switch', + 'match', + 'case', + 'default', + 'for', + 'each', + 'in', + 'while', + 'loop', + 'continue', + 'break', + 'do', + 'goto', + 'next', + 'end', + 'sub', + 'throw', + 'try', + 'catch', + 'finally', + 'enum', + 'delegate', + 'function', + 'func', + 'fun', + 'fn', + 'return', + 'yield', + 'async', + 'await', + 'require', + 'include', + 'import', + 'imports', + 'export', + 'exports', + 'from', + 'as', + 'using', + 'use', + 'internal', + 'module', + 'namespace', + 'where', + 'select', + 'struct', + 'union', + 'new', + 'delete', + 'this', + 'super', + 'base', + 'class', + 'interface', + 'abstract', + 'static', + 'public', + 'private', + 'protected', + 'virtual', + 'partial', + 'override', + 'extends', + 'implements', + 'constructor' +]; + +const keywords = _keywords + .concat(_keywords.map(k => k[0].toUpperCase() + k.substr(1))) + .concat(_keywords.map(k => k.toUpperCase())) + .sort((a, b) => b.length - a.length); + +const symbols = [ + '=', + '+', + '-', + '*', + '/', + '%', + '~', + '^', + '&', + '|', + '>', + '<', + '!', + '?' +]; + +const elements = [ + // comment + code => { + if (code.substr(0, 2) != '//') return null; + const match = code.match(/^\/\/(.+?)(\n|$)/); + if (!match) return null; + const comment = match[0]; + return { + html: `<span class="comment">${escape(comment)}</span>`, + next: comment.length + }; + }, + + // block comment + code => { + const match = code.match(/^\/\*([\s\S]+?)\*\//); + if (!match) return null; + return { + html: `<span class="comment">${escape(match[0])}</span>`, + next: match[0].length + }; + }, + + // string + code => { + if (!/^['"`]/.test(code)) return null; + const begin = code[0]; + let str = begin; + let thisIsNotAString = false; + for (let i = 1; i < code.length; i++) { + const char = code[i]; + if (char == '\\') { + str += char; + str += code[i + 1] || ''; + i++; + continue; + } else if (char == begin) { + str += char; + break; + } else if (char == '\n' || i == (code.length - 1)) { + thisIsNotAString = true; + break; + } else { + str += char; + } + } + if (thisIsNotAString) { + return null; + } else { + return { + html: `<span class="string">${escape(str)}</span>`, + next: str.length + }; + } + }, + + // regexp + code => { + if (code[0] != '/') return null; + let regexp = ''; + let thisIsNotARegexp = false; + for (let i = 1; i < code.length; i++) { + const char = code[i]; + if (char == '\\') { + regexp += char; + regexp += code[i + 1] || ''; + i++; + continue; + } else if (char == '/') { + break; + } else if (char == '\n' || i == (code.length - 1)) { + thisIsNotARegexp = true; + break; + } else { + regexp += char; + } + } + + if (thisIsNotARegexp) return null; + if (regexp == '') return null; + if (regexp[0] == ' ' && regexp[regexp.length - 1] == ' ') return null; + + return { + html: `<span class="regexp">/${escape(regexp)}/</span>`, + next: regexp.length + 2 + }; + }, + + // label + code => { + if (code[0] != '@') return null; + const match = code.match(/^@([a-zA-Z_-]+?)\n/); + if (!match) return null; + const label = match[0]; + return { + html: `<span class="label">${label}</span>`, + next: label.length + }; + }, + + // number + (code, i, source) => { + const prev = source[i - 1]; + if (prev && /[a-zA-Z]/.test(prev)) return null; + if (!/^[\-\+]?[0-9\.]+/.test(code)) return null; + const match = code.match(/^[\-\+]?[0-9\.]+/)[0]; + if (match) { + return { + html: `<span class="number">${match}</span>`, + next: match.length + }; + } else { + return null; + } + }, + + // nan + (code, i, source) => { + const prev = source[i - 1]; + if (prev && /[a-zA-Z]/.test(prev)) return null; + if (code.substr(0, 3) == 'NaN') { + return { + html: `<span class="nan">NaN</span>`, + next: 3 + }; + } else { + return null; + } + }, + + // method + code => { + const match = code.match(/^([a-zA-Z_-]+?)\(/); + if (!match) return null; + + if (match[1] == '-') return null; + + return { + html: `<span class="method">${match[1]}</span>`, + next: match[1].length + }; + }, + + // property + (code, i, source) => { + const prev = source[i - 1]; + if (prev != '.') return null; + + const match = code.match(/^[a-zA-Z0-9_-]+/); + if (!match) return null; + + return { + html: `<span class="property">${match[0]}</span>`, + next: match[0].length + }; + }, + + // keyword + (code, i, source) => { + const prev = source[i - 1]; + if (prev && /[a-zA-Z]/.test(prev)) return null; + + const match = keywords.filter(k => code.substr(0, k.length) == k)[0]; + if (match) { + if (/^[a-zA-Z]/.test(code.substr(match.length))) return null; + return { + html: `<span class="keyword ${match}">${match}</span>`, + next: match.length + }; + } else { + return null; + } + }, + + // symbol + code => { + const match = symbols.filter(s => code[0] == s)[0]; + if (match) { + return { + html: `<span class="symbol">${match}</span>`, + next: 1 + }; + } else { + return null; + } + } +]; + +// specify lang is todo +export default (source: string, lang?: string) => { + let code = source; + let html = ''; + + let i = 0; + + function push(token) { + html += token.html; + code = code.substr(token.next); + i += token.next; + } + + while (code != '') { + const parsed = elements.some(el => { + const e = el(code, i, source); + if (e) { + push(e); + return true; + } else { + return false; + } + }); + + if (!parsed) { + push({ + html: escape(code[0]), + next: 1 + }); + } + } + + return html; +}; diff --git a/src/common/text/elements/bold.ts b/src/common/text/elements/bold.ts new file mode 100644 index 0000000000..ce25764457 --- /dev/null +++ b/src/common/text/elements/bold.ts @@ -0,0 +1,14 @@ +/** + * Bold + */ + +module.exports = text => { + const match = text.match(/^\*\*(.+?)\*\*/); + if (!match) return null; + const bold = match[0]; + return { + type: 'bold', + content: bold, + bold: bold.substr(2, bold.length - 4) + }; +}; diff --git a/src/common/text/elements/code.ts b/src/common/text/elements/code.ts new file mode 100644 index 0000000000..4821e95fe2 --- /dev/null +++ b/src/common/text/elements/code.ts @@ -0,0 +1,17 @@ +/** + * Code (block) + */ + +import genHtml from '../core/syntax-highlighter'; + +module.exports = text => { + const match = text.match(/^```([\s\S]+?)```/); + if (!match) return null; + const code = match[0]; + return { + type: 'code', + content: code, + code: code.substr(3, code.length - 6).trim(), + html: genHtml(code.substr(3, code.length - 6).trim()) + }; +}; diff --git a/src/common/text/elements/emoji.ts b/src/common/text/elements/emoji.ts new file mode 100644 index 0000000000..e24231a223 --- /dev/null +++ b/src/common/text/elements/emoji.ts @@ -0,0 +1,14 @@ +/** + * Emoji + */ + +module.exports = text => { + const match = text.match(/^:[a-zA-Z0-9+-_]+:/); + if (!match) return null; + const emoji = match[0]; + return { + type: 'emoji', + content: emoji, + emoji: emoji.substr(1, emoji.length - 2) + }; +}; diff --git a/src/common/text/elements/hashtag.ts b/src/common/text/elements/hashtag.ts new file mode 100644 index 0000000000..ee57b140b8 --- /dev/null +++ b/src/common/text/elements/hashtag.ts @@ -0,0 +1,19 @@ +/** + * Hashtag + */ + +module.exports = (text, i) => { + if (!(/^\s#[^\s]+/.test(text) || (i == 0 && /^#[^\s]+/.test(text)))) return null; + const isHead = text[0] == '#'; + const hashtag = text.match(/^\s?#[^\s]+/)[0]; + const res: any[] = !isHead ? [{ + type: 'text', + content: text[0] + }] : []; + res.push({ + type: 'hashtag', + content: isHead ? hashtag : hashtag.substr(1), + hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2) + }); + return res; +}; diff --git a/src/common/text/elements/inline-code.ts b/src/common/text/elements/inline-code.ts new file mode 100644 index 0000000000..9f9ef51a2b --- /dev/null +++ b/src/common/text/elements/inline-code.ts @@ -0,0 +1,17 @@ +/** + * Code (inline) + */ + +import genHtml from '../core/syntax-highlighter'; + +module.exports = text => { + const match = text.match(/^`(.+?)`/); + if (!match) return null; + const code = match[0]; + return { + type: 'inline-code', + content: code, + code: code.substr(1, code.length - 2).trim(), + html: genHtml(code.substr(1, code.length - 2).trim()) + }; +}; diff --git a/src/common/text/elements/link.ts b/src/common/text/elements/link.ts new file mode 100644 index 0000000000..35563ddc3d --- /dev/null +++ b/src/common/text/elements/link.ts @@ -0,0 +1,19 @@ +/** + * Link + */ + +module.exports = text => { + const match = text.match(/^\??\[([^\[\]]+?)\]\((https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+?)\)/); + if (!match) return null; + const silent = text[0] == '?'; + const link = match[0]; + const title = match[1]; + const url = match[2]; + return { + type: 'link', + content: link, + title: title, + url: url, + silent: silent + }; +}; diff --git a/src/common/text/elements/mention.ts b/src/common/text/elements/mention.ts new file mode 100644 index 0000000000..d05a76649d --- /dev/null +++ b/src/common/text/elements/mention.ts @@ -0,0 +1,17 @@ +/** + * Mention + */ +import parseAcct from '../../../common/user/parse-acct'; + +module.exports = text => { + const match = text.match(/^(?:@[a-zA-Z0-9\-]+){1,2}/); + if (!match) return null; + const mention = match[0]; + const { username, host } = parseAcct(mention.substr(1)); + return { + type: 'mention', + content: mention, + username, + host + }; +}; diff --git a/src/common/text/elements/quote.ts b/src/common/text/elements/quote.ts new file mode 100644 index 0000000000..cc8cfffdc4 --- /dev/null +++ b/src/common/text/elements/quote.ts @@ -0,0 +1,14 @@ +/** + * Quoted text + */ + +module.exports = text => { + const match = text.match(/^"([\s\S]+?)\n"/); + if (!match) return null; + const quote = match[0]; + return { + type: 'quote', + content: quote, + quote: quote.substr(1, quote.length - 2).trim(), + }; +}; diff --git a/src/common/text/elements/url.ts b/src/common/text/elements/url.ts new file mode 100644 index 0000000000..1003aff9c3 --- /dev/null +++ b/src/common/text/elements/url.ts @@ -0,0 +1,14 @@ +/** + * URL + */ + +module.exports = text => { + const match = text.match(/^https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+/); + if (!match) return null; + const url = match[0]; + return { + type: 'url', + content: url, + url: url + }; +}; diff --git a/src/common/text/index.ts b/src/common/text/index.ts new file mode 100644 index 0000000000..1e2398dc38 --- /dev/null +++ b/src/common/text/index.ts @@ -0,0 +1,72 @@ +/** + * Misskey Text Analyzer + */ + +const elements = [ + require('./elements/bold'), + require('./elements/url'), + require('./elements/link'), + require('./elements/mention'), + require('./elements/hashtag'), + require('./elements/code'), + require('./elements/inline-code'), + require('./elements/quote'), + require('./elements/emoji') +]; + +export default (source: string) => { + + if (source == '') { + return null; + } + + const tokens = []; + + function push(token) { + if (token != null) { + tokens.push(token); + source = source.substr(token.content.length); + } + } + + let i = 0; + + // パース + while (source != '') { + const parsed = elements.some(el => { + let _tokens = el(source, i); + if (_tokens) { + if (!Array.isArray(_tokens)) { + _tokens = [_tokens]; + } + _tokens.forEach(push); + return true; + } else { + return false; + } + }); + + if (!parsed) { + push({ + type: 'text', + content: source[0] + }); + } + + i++; + } + + // テキストを纏める + tokens[0] = [tokens[0]]; + return tokens.reduce((a, b) => { + if (a[a.length - 1].type == 'text' && b.type == 'text') { + const tail = a.pop(); + return a.concat({ + type: 'text', + content: tail.content + b.content + }); + } else { + return a.concat(b); + } + }); +}; diff --git a/src/common/user/get-acct.ts b/src/common/user/get-acct.ts new file mode 100644 index 0000000000..9afb03d88b --- /dev/null +++ b/src/common/user/get-acct.ts @@ -0,0 +1,3 @@ +export default user => { + return user.host === null ? user.username : `${user.username}@${user.host}`; +}; diff --git a/src/common/user/get-summary.ts b/src/common/user/get-summary.ts new file mode 100644 index 0000000000..47592c86ba --- /dev/null +++ b/src/common/user/get-summary.ts @@ -0,0 +1,18 @@ +import { ILocalAccount, IUser } from '../../models/user'; +import getAcct from './get-acct'; + +/** + * ユーザーを表す文字列を取得します。 + * @param user ユーザー + */ +export default function(user: IUser): string { + let string = `${user.name} (@${getAcct(user)})\n` + + `${user.postsCount}投稿、${user.followingCount}フォロー、${user.followersCount}フォロワー\n`; + + if (user.host === null) { + const account = user.account as ILocalAccount; + string += `場所: ${account.profile.location}、誕生日: ${account.profile.birthday}\n`; + } + + return string + `「${user.description}」`; +} diff --git a/src/common/user/parse-acct.ts b/src/common/user/parse-acct.ts new file mode 100644 index 0000000000..ef1f55405d --- /dev/null +++ b/src/common/user/parse-acct.ts @@ -0,0 +1,4 @@ +export default acct => { + const splitted = acct.split('@', 2); + return { username: splitted[0], host: splitted[1] || null }; +}; |