├── .gitignore ├── .npmignore ├── ChangeLog.md ├── LICENSE ├── README.md ├── bin ├── client.js └── server.js ├── lib ├── game.js ├── lobby.js └── passport.js ├── package-lock.json ├── package.json └── test └── lobby.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | auth/ 3 | session/ 4 | coverage/ 5 | .nyc_output/ 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | auth/ 2 | session/ 3 | .nyc_output/ 4 | coverage/ 5 | test/ 6 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | ### v1.4.4 / 2025-04-24 2 | 3 | - ステータス表示に現在の局を表示するよう修正 4 | - ステータス表示のCSSを修正 5 | - 脆弱性警告に対処 6 | - @babel/helpers 7.26.7 → 7.27.0 7 | 8 | ### v1.4.3 / 2025-02-09 9 | 10 | - 管理者不在のルームを削除する処理を追加 11 | - 対局終了時のcallback関数から2度目の呼び出しの考慮を削除 12 | 13 | ### v1.4.2 / 2025-02-05 14 | 15 | - 入室済みのユーザがルームを作成できるバグを修正 16 | - 対局開始後は退室できないよう修正 17 | - 対局終了時のcallback関数が2度呼び出されると例外が発生する問題に対処 18 | - ステータス表示の日時表示のタイムゾーンをロケールと一致させた 19 | - ステータス表示に稼働時間を追加 20 | - ステータス表示にデバッグ出力モードを追加 21 | - フェイルセーフの例外ハンドラを削除 22 | 23 | ### v1.4.1 / 2025-01-31 24 | 25 | - 卓組み中の異常でサーバーが終了する問題に仮対処 26 | - ステータス表示に接続者のないルームを表示しないよう修正 27 | - npmの配布物からテストを削除 28 | 29 | ## v1.4.0 / 2025-01-31 30 | 31 | - ステータス表示機能を追加 32 | - ユニットテストを追加 33 | 34 | ### v1.3.7 / 2025-01-23 35 | 36 | - 既に対局がはじまっているルームに入室できてしまうバグを修正 37 | 38 | ### v1.3.6 / 2025-01-23 39 | 40 | - ルーム管理者の切断が表示に反映されないバグを修正 41 | 42 | ### v1.3.5 / 2025-01-22 43 | 44 | - 偽ラグを発生させる処理を追加 45 | 46 | ### v1.3.4 / 2025-01-21 47 | 48 | - 管理者が退室させると同時にそのユーザが切断するとサーバが異常終了するバグを修正 49 | 50 | ### v1.3.3 / 2025-01-20 51 | 52 | - User-Agent のバージョンを package.json から取得するよう修正 53 | - typo 修正: agant → agent 54 | 55 | ### v1.3.2 / 2025-01-19 56 | 57 | - HTTPクライアントを http/https から fetch に変更 58 | 59 | ### v1.3.1 / 2025-01-19 60 | 61 | - ボットが開槓を処理していないバグを修正 62 | - ボットに接続エラーのメッセージを追加 63 | - 空文字列のルームを指定するとボットがルームを作成してしまう問題に対処 64 | 65 | ## v1.3.0 / 2025-01-18 66 | 67 | - 麻雀ボット(majiang-bot)の機能を追加 68 | - @kobalab/majiang-ai 1.0.13 をインストール 69 | - soket.io-client 4.8.1 をインストール 70 | - @kobalab/majiang-core 1.2.1 → 1.3.2 71 | - パッケージを最新化 72 | - express 4.21.0 → 4.21.2 73 | - express-session 1.18.0 → 1.18.1 74 | - socket.io 4.8.0 → 4.8.1 75 | 76 | ### v1.2.6 / 2024-11-10 77 | 78 | - session-file-store の出すログメッセージを抑止 79 | - 脆弱性警告に対処 80 | - cookie 0.4.2 → 0.7.1 81 | 82 | ### v1.2.5 / 2024-09-26 83 | 84 | - パッケージを最新化 85 | - express 4.18.2 → 4.21.0 86 | - socket.io 4.7.4 → 4.8.0 87 | - 脆弱性警告に対処 88 | - ws 8.11.0 → 8.17.1 89 | 90 | ### v1.2.4 / 2024-03-03 91 | 92 | - 対局中はクライアントにルーム情報を送信しないよう再修正(修正もれの対応) 93 | 94 | ### v1.2.3 / 2024-03-01 95 | 96 | - 持ち時間に秒読みのみを指定するとサーバで即座にタイムアウトが発生するバグを修正 97 | - 対局中はクライアントにルーム情報を送信しないよう修正 98 | - イベントリスナー解放のタイミングを開局時から終局時に変更 99 | 100 | ### v1.2.2 / 2024-02-29 101 | 102 | - 持ち時間のタイムアウトをサーバ側でも検知するよう修正 103 | - 回線切断で持ち時間が消費されないよう修正 104 | - 回線接続/切断時にクライアントにユーザ情報を通知するよう変更 105 | - room_noの重複を防ぐ処理を追加 106 | 107 | ### v1.2.1 / 2024-02-25 108 | 109 | - サーバーを異常終了させないために、対局中に発生した例外を捕捉する処理を追加 110 | - クライアントからの重複した応答を無視する処理を追加 111 | - String の非推奨のメソッドを変更: substr() → slice() 112 | - @kobalab/majiang-core 1.2.0 → 1.2.1 113 | 114 | ## v1.2.0 / 2024-02-16 115 | 116 | - 時間切れの処理を追加 117 | 118 | ### v1.1.2 / 2024-02-15 119 | 120 | - 不正なパラメータでサーバーが異常終了するバグを修正 121 | - READMEにブログへのリンクを追加 122 | 123 | ### v1.1.1 / 2024-02-14 124 | 125 | - 終局後にプレーヤーがゲームに再接続することがあるバグを修正 126 | - 終局画面表示中に回線切断があるとサーバが異常終了するバグを修正 127 | 128 | ## v1.1.0 / 2024-02-14 129 | 130 | - Google認証に対応 131 | 132 | ### v1.0.1 / 2024-02-12 133 | 134 | - 回線切断時にルームから退出するよう変更 135 | - status_log の出力を追加 136 | 137 | # v1.0.0 / 2024-02-11 138 | 139 | - 正式版リリース 140 | 141 | ## v0.5.0 / 2024-02-11 142 | 143 | - ログアウト機能を追加 144 | 145 | ## v0.4.0 / 2024-02-10 146 | 147 | - 退室機能を追加 148 | - セッションデータを保存する機能を追加 149 | 150 | ## v0.3.0 / 2024-02-10 151 | 152 | - ルールを選択可能にした 153 | 154 | ## v0.2.0 / 2024-02-09 155 | 156 | - 回線切断/復旧の通知を追加 157 | - オプションの名称を docs → docroot に変更 158 | 159 | ### v0.1.1 / 2024-02-03 160 | 161 | - npm に公開 162 | 163 | ## v0.1.0 / 2024-02-03 164 | 165 | - β版リリース 166 | 167 | ## v0.0.1 / 2024-01-31 168 | 169 | - α版リリース 170 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Satoshi Kobayashi 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # majiang-server 2 | 3 | 麻雀サーバー 4 | 5 | WebSocket(Socket.IO)による麻雀サーバーの実装です。 6 | [電脳麻将](https://github.com/kobalab/Majiang) ver.2.3.0 で追加したネット対戦は、本サーバーに接続して実現しています。 7 | 8 | ## デモ 9 | https://kobalab.net/majiang/netplay.html 10 | 11 | ## インストール 12 | ```bash 13 | $ npm i -g @kobalab/majiang-server 14 | ``` 15 | 16 | ## 使用方法 17 | 18 | ### majiang-server [ *options*... ] 19 |
20 |
--port, -p
21 |
麻雀サーバーを起動するポート番号(デフォルトは 4615)
22 |
--baseurl, -b
23 |
socket.io のエントリポイント(デフォルトは /server)
24 |
--callback, -c
25 |
認証からの復帰URL(デフォルトは /)
26 |
--docroot, -d
27 |
サーバの配信する静的コンテンツの配置ディレクトリ(省略可能)
28 |
--oauth, -o
29 |
OAuth認証定義ファイルの配置ディレクトリ(省略可能)
30 |
--store, -s
31 |
セッションデータを保存するディレクトリ(省略可能)
32 |
--status, -S
33 |
/server/status でステータス表示を有効にする
34 |
35 | 36 | **関連記事:** [麻雀サーバーの使い方](https://blog.kobalab.net/entry/2024/02/15/081605) 37 | 38 | ### majiang-bot [ *options*... ] [ *server-url* ] 39 | 麻雀サーバーにAIのボットを接続する。 40 | ```bash 41 | $ majiang-bot -r A1234 -n '麻雀ロボ' https://kobalab.net/majiang/server 42 | ``` 43 |
44 |
--room, -r
45 |
入室するルーム
46 |
--name, -n
47 |
対局者名(デフォルトは *ボット*)
48 |
--verbose, -v
49 |
標準出力にデバッグログを出力する
50 |
server-url
51 |
麻雀サーバーのURL(デフォルトは http://127.0.0.1:4615/server)。
52 | デモサイトに接続するときは https://kobalab.net/majiang/server を指定すればよい。
53 |
54 | 55 | ## ライセンス 56 | [MIT](https://github.com/kobalab/majiang-server/blob/master/LICENSE) 57 | 58 | ## 作者 59 | [Satoshi Kobayashi](https://github.com/kobalab) 60 | -------------------------------------------------------------------------------- /bin/client.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | const { version } = require('../package.json'); 6 | const agent = 'majiang-bot/' + version.replace(/^(\d+\.\d+).*$/,'$1'); 7 | 8 | const io = require('socket.io-client'); 9 | 10 | const Player = require('@kobalab/majiang-ai'); 11 | const player = new Player(); 12 | 13 | let cookie; 14 | 15 | function login(url, name, room) { 16 | 17 | fetch(url + '/auth/', { 18 | method: 'POST', 19 | headers: { 'User-Agent': agent }, 20 | body: new URLSearchParams({ name: name, passwd: '*'}), 21 | redirect: 'manual' 22 | }).then(res=>{ 23 | for (let c of (res.headers.get('Set-Cookie')||'').split(/,\s*/)) { 24 | if (! c.match(/^MAJIANG=/)) continue; 25 | cookie = c.replace(/^MAJIANG=/,'').replace(/; .*$/,''); 26 | init(url, room); 27 | break; 28 | } 29 | if (! cookie) console.log('ログインエラー:', url); 30 | }).catch(err=>{ 31 | console.log('接続エラー:', url); 32 | }); 33 | } 34 | 35 | function logout() { 36 | 37 | fetch(url + '/logout', { 38 | method: 'POST', 39 | headers: { 'User-Agent': agent, 40 | 'Cookie': `MAJIANG=${cookie}`}, 41 | }).then(res=>{ 42 | process.exit(); 43 | }); 44 | } 45 | 46 | function error(msg) { 47 | console.log('ERROR:', msg); 48 | logout(); 49 | } 50 | 51 | function init(url, room) { 52 | 53 | const server = url.replace(/^(https?:\/\/[^\/]*)\/.*$/,'$1'); 54 | const path = url.replace(/^https?:\/\/[^\/]*/,'').replace(/\/$/,''); 55 | const sock = io(server, { 56 | path: `${path}/socket.io/`, 57 | extraHeaders: { 58 | 'User-Agent': agent, 59 | Cookie: `MAJIANG=${cookie}`, 60 | } 61 | }); 62 | 63 | if (argv.verbose) sock.onAny(console.log); 64 | sock.on('ERROR', error); 65 | sock.on('END', logout); 66 | sock.on('ROOM', ()=>{ sock.on('HELLO', logout)}); 67 | sock.on('GAME', (msg)=>{ 68 | if (msg.seq) { 69 | player.action(msg, (reply = {})=>{ 70 | reply.seq = msg.seq; 71 | sock.emit('GAME', reply); 72 | }); 73 | } 74 | else { 75 | player.action(msg); 76 | } 77 | }); 78 | 79 | process.on('SIGTERM', logout); 80 | process.on('SIGINT', logout); 81 | 82 | sock.emit('ROOM', room); 83 | } 84 | 85 | const argv = require('yargs') 86 | .usage('Usage: $0 [ server-url ]') 87 | .option('name', { alias: 'n', default: '*ボット*'}) 88 | .option('room', { alias: 'r', type: 'string', demandOption: true }) 89 | .option('verbose', { alias: 'v', boolean: true }) 90 | .argv; 91 | 92 | const url = (argv._[0] || 'http://127.0.0.1:4615/server').replace(/\/$/,''); 93 | const room = argv.room || '-'; 94 | 95 | login(url, argv.name, room); 96 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | 8 | const yargs = require('yargs'); 9 | const argv = yargs 10 | .usage('Usage: $0 [ options... ]') 11 | .option('port', { alias: 'p', default: 4615 }) 12 | .option('baseurl', { alias: 'b', default: '/server'}) 13 | .option('callback', { alias: 'c', default: '/' }) 14 | .option('docroot', { alias: 'd' }) 15 | .option('oauth', { alias: 'o' }) 16 | .option('store', { alias: 's' }) 17 | .option('status', { alias: 'S', boolean: true }) 18 | .argv; 19 | const port = argv.port; 20 | const base = ('' + argv.baseurl) 21 | .replace(/^(?!\/.*)/, '/$&') 22 | .replace(/\/$/,''); 23 | const back = argv.callback; 24 | const auth = argv.oauth && path.resolve(argv.oauth); 25 | const docs = argv.docroot && path.resolve(argv.docroot); 26 | const stat = argv.status; 27 | 28 | const express = require('express'); 29 | const store = ! argv.store ? null 30 | : new (require('session-file-store')( 31 | require('express-session')))( 32 | { path: path.resolve(argv.store), 33 | logFn: ()=>{} }); 34 | const session = require('express-session')({ 35 | name: 'MAJIANG', 36 | secret: 'keyboard cat', 37 | resave: false, 38 | saveUninitialized: false, 39 | store: store, 40 | rolling: true, 41 | cookie: { maxAge: 1000*60*60*24*14 } }); 42 | const passport = require('../lib/passport')(auth); 43 | 44 | const app = express(); 45 | const http = require('http').createServer(app); 46 | const io = require('socket.io')(http, { path: `${base}/socket.io/` }); 47 | 48 | const lobby = require('../lib/lobby')(io); 49 | 50 | app.use(session); 51 | app.use(passport.initialize()); 52 | app.use(passport.session()); 53 | app.use(express.urlencoded({ limit: '4mb', extended: false })); 54 | app.post(`${base}/auth/`, passport.authenticate('local', 55 | { successRedirect: back, 56 | failureRedirect: back })); 57 | if (auth && fs.existsSync(path.join(auth, 'hatena.json'))) { 58 | app.post(`${base}/auth/hatena`, passport.authenticate('hatena', 59 | { scope: ['read_public'] })); 60 | app.get(`${base}/auth/hatena`, passport.authenticate('hatena', 61 | { successRedirect: back })); 62 | } 63 | if (auth && fs.existsSync(path.join(auth, 'google.json'))) { 64 | app.post(`${base}/auth/google`, passport.authenticate('google', 65 | { scope: ['profile'] })); 66 | app.get(`${base}/auth/google`, (req, res, next)=>{ 67 | if (req.query.error) res.redirect(302, back); 68 | else next(); }, 69 | passport.authenticate('google', 70 | { successRedirect: back })); 71 | } 72 | app.post(`${base}/logout`, (req, res)=>{ 73 | req.session.destroy(); 74 | res.clearCookie('MAJIANG'); 75 | res.redirect(302, back); 76 | }); 77 | if (stat) { 78 | app.get(`${base}/status`, (req, res)=> 79 | res.send(lobby.status(req.query.refresh, req.query.all, 80 | req.query.debug))); 81 | } 82 | if (docs) app.use(express.static(docs)); 83 | app.use((req, res)=>res.status(404).send('

Not Found

')); 84 | 85 | const wrap = (middle_wear)=> 86 | (socket, next)=> middle_wear(socket.request, {}, next); 87 | 88 | io.use(wrap(session)); 89 | io.use(wrap(passport.initialize())); 90 | io.use(wrap(passport.session())); 91 | 92 | http.listen(port, ()=>{ 93 | console.log(`Server start on http://127.0.0.1:${port}${base}/`); 94 | }).on('error', (e)=>{ 95 | console.log('' + e); 96 | process.exit(-1); 97 | }); 98 | -------------------------------------------------------------------------------- /lib/game.js: -------------------------------------------------------------------------------- 1 | /* 2 | * game 3 | */ 4 | "use strict"; 5 | 6 | const Majiang = require('@kobalab/majiang-core'); 7 | 8 | function get_timer(type, limit, allowed = 0, wait) { 9 | if (type == 'jieju') return; 10 | if (type.match(/^(kaiju|hule|pingju)$/)) return wait ? [ wait ] : null; 11 | else return [ limit, allowed ]; 12 | } 13 | 14 | function fakelag(type) { 15 | if (type != 'dapai') return 0; 16 | return Math.pow(Math.random(), 24) * 1600; 17 | } 18 | 19 | module.exports = class Game extends Majiang.Game { 20 | 21 | constructor(socks, callback, rule, title, timer) { 22 | 23 | super(socks, callback, rule, title); 24 | 25 | this._model.title = this._model.title.replace(/\n/, ': ネット対戦\n'); 26 | this._model.player = socks.map(s=>s ? s.request.user.name : '(NOP)'); 27 | this._uid = socks.map(s=>s ? s.request.user.uid : null); 28 | this._seq = 0; 29 | this._timer = timer; 30 | this._time_allowed = []; 31 | this._time_limit = []; 32 | this._timer_id = []; 33 | socks.forEach(s=>this.connect(s)); 34 | } 35 | 36 | connect(sock) { 37 | if (! sock) return; 38 | let id = this._uid.indexOf(sock.request.user.uid); 39 | this._players[id] = sock; 40 | sock.emit('START'); 41 | if (this._seq) { 42 | let msg = { kaiju: { 43 | id: id, 44 | rule: this._rule, 45 | title: this._model.title, 46 | player: this._model.player, 47 | qijia: this._model.qijia, 48 | log: this._paipu.log 49 | } }; 50 | sock.emit('GAME', msg); 51 | } 52 | sock.on('GAME', (reply)=>this.reply(id, reply)); 53 | let msg = { players: this._players.map(s => s && s.request.user ) }; 54 | this.notify_players('players', [ msg, msg, msg, msg ]); 55 | } 56 | 57 | disconnect(sock) { 58 | if (! sock) return; 59 | let id = this._uid.indexOf(sock.request.user.uid); 60 | this._players[id] = null; 61 | if (! this._players.find(s=>s)) { 62 | this.stop(this._callback); 63 | } 64 | if (! this._reply[id]) { 65 | this.reply(id, { seq: this._seq }); 66 | } 67 | let msg = { players: this._players.map(s => s && s.request.user ) }; 68 | this.notify_players('players', [ msg, msg, msg, msg ]); 69 | } 70 | 71 | notify_players(type, msg) { 72 | 73 | for (let l = 0; l < 4; l++) { 74 | let id = this._model.player_id[l]; 75 | if (this._players[id]) 76 | this._players[id].emit('GAME', msg[l]); 77 | } 78 | } 79 | 80 | call_players(type, msg, timeout) { 81 | 82 | timeout = this._speed == 0 ? 0 83 | : timeout == null ? this._speed * 200 + fakelag(type) 84 | : timeout; 85 | this._status = type; 86 | this._reply = []; 87 | this._seq++; 88 | for (let l = 0; l < 4; l++) { 89 | let id = this._model.player_id[l]; 90 | msg[l].seq = this._seq; 91 | this._time_limit[id] = null; 92 | if (this._players[id] && this._timer) { 93 | msg[l].timer = get_timer(type, this._timer[0], 94 | this._time_allowed[id], 95 | this._timer[2]); 96 | if (msg[l].timer) { 97 | let timer = msg[l].timer.reduce((x, y) => x + y) * 1000 98 | + 500; 99 | this._time_limit[id] = Date.now() + timer; 100 | this._timer_id[id] = setTimeout(()=>{ 101 | this.reply(id, { seq: this._seq }); 102 | }, timer); 103 | } 104 | } 105 | if (this._players[id]) 106 | this._players[id].emit('GAME', msg[l]); 107 | else this._reply[id] = {}; 108 | } 109 | if (type == 'jieju') { 110 | this._callback(this._paipu); 111 | return; 112 | } 113 | this._timeout_id = setTimeout(()=>this.next(), timeout); 114 | } 115 | 116 | reply(id, reply) { 117 | if (reply.seq != this._seq) return; 118 | if (this._reply[id]) return; 119 | this._timer_id[id] = clearTimeout(this._timer_id[id]); 120 | if (this._time_limit[id]) { 121 | let allowed = (this._time_limit[id] - Date.now()) / 1000; 122 | if (! this._status.match(/^(kaiju|hule|pingju)$/) 123 | && this._time_allowed[id]) 124 | { 125 | this._time_allowed[id] 126 | = Math.ceil(Math.min(Math.max(allowed, 0), 127 | this._time_allowed[id])); 128 | } 129 | } 130 | this._reply[id] = reply; 131 | if (this._status == 'jieju') { 132 | if (this._players[id]) { 133 | this._players[id].removeAllListeners('GAME'); 134 | this._players[id].emit('END', this._paipu); 135 | } 136 | return; 137 | } 138 | if (this._reply.filter(x=>x).length < 4) return; 139 | if (! this._timeout_id) 140 | this._timeout_id = setTimeout(()=>this.next(), 0); 141 | } 142 | 143 | say(name, l) { 144 | let msg = []; 145 | for (let id = 0; id < 4; id++) { 146 | msg[id] = { say: { l: l, name: name } }; 147 | } 148 | this.notify_players('say', msg); 149 | } 150 | 151 | delay(callback, timeout) { 152 | super.delay(()=>{ 153 | try { 154 | callback(); 155 | } 156 | catch(e) { 157 | console.error(e.stack); 158 | this._timeout_id = clearTimeout(this._timeout_id); 159 | this._players.forEach(s=>s && s.emit('END')); 160 | this._callback(); 161 | } 162 | }, timeout); 163 | } 164 | 165 | next() { 166 | try { 167 | super.next(); 168 | } 169 | catch(e) { 170 | console.error(e.stack); 171 | this._timeout_id = clearTimeout(this._timeout_id); 172 | this._players.forEach(s=>s && s.emit('END')); 173 | this._callback(); 174 | } 175 | } 176 | 177 | qipai(shan) { 178 | if (this._timer) 179 | this._time_allowed = [ this._timer[1], this._timer[1], 180 | this._timer[1], this._timer[1] ]; 181 | super.qipai(shan); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /lib/lobby.js: -------------------------------------------------------------------------------- 1 | /* 2 | * lobby 3 | */ 4 | "use strict"; 5 | 6 | const { version } = require('../package.json'); 7 | const Game = require('./game'); 8 | 9 | function get_user(sock) { 10 | 11 | let session_id = sock.request.sessionID; 12 | let user = sock.request.user; 13 | 14 | if (! user) return; 15 | 16 | user.uid = user.uid ?? session_id; 17 | return user; 18 | } 19 | 20 | function exptime(sock) { 21 | if (sock.request.user.uid.match(/@/)) return; 22 | return sock.request.session.cookie.expires; 23 | } 24 | 25 | const style 26 | = '\n' 37 | 38 | function print_user(USER) { 39 | return function(uid) { 40 | let icon = USER[uid].user.icon || '../img/icon.png'; 41 | let name = USER[uid].user.name; 42 | let title = USER[uid].user.icon ? uid : ''; 43 | return (USER[uid].sock ? '' : '') 44 | + ` ${name}`; 45 | } 46 | } 47 | 48 | function print_room(USER, ROOM) { 49 | function jushu(model) { 50 | return ['東','南','西','北'][model.zhuangfeng] 51 | + ['一','二','三','四'][model.jushu] + '局'; 52 | } 53 | const user = print_user(USER); 54 | return function(room_no) { 55 | if (room_no) { 56 | return `ルーム: ${room_no}` 57 | + (ROOM[room_no].game ? 58 | `【${jushu(ROOM[room_no].game._model)}】` : '') 59 | + '\n\n'; 64 | } 65 | else { 66 | return '(接続中)\n\n'; 71 | } 72 | } 73 | } 74 | 75 | class Lobby { 76 | 77 | constructor(io) { 78 | 79 | this.USER = {}; 80 | this.ROOM = {}; 81 | this._start_date = new Date(); 82 | 83 | io.on('connection', (sock)=> this.connect(sock)); 84 | } 85 | 86 | connect(sock) { 87 | 88 | let user = get_user(sock); 89 | 90 | sock.emit('HELLO', user); 91 | 92 | if (! user) { 93 | sock.disconnect(true); 94 | return; 95 | } 96 | 97 | if (! this.USER[user.uid]) { 98 | this.USER[user.uid] = { user: user, sock: sock }; 99 | } 100 | else if (this.USER[user.uid].sock) { 101 | sock.emit('ERROR', '既に接続済みです'); 102 | sock.disconnect(true); 103 | return; 104 | } 105 | else { 106 | this.USER[user.uid].sock = sock; 107 | let room_no = this.USER[user.uid].room_no; 108 | if (this.ROOM[room_no].game) { 109 | this.ROOM[room_no].game.connect(sock); 110 | } 111 | else { 112 | delete this.ROOM[room_no].exptime; 113 | this.send_room_info(room_no); 114 | } 115 | } 116 | 117 | sock.on('disconnect', (reason)=> this.disconnect(sock)); 118 | sock.on('ROOM', (room_no, uid)=> this.room(sock, room_no, uid)); 119 | sock.on('START', (room_no, rule, timer)=> 120 | this.start(sock, room_no, rule, timer)); 121 | this.status_log(); 122 | } 123 | 124 | disconnect(sock) { 125 | 126 | let user = get_user(sock); 127 | 128 | delete this.USER[user.uid].sock; 129 | 130 | let room_no = this.USER[user.uid].room_no; 131 | if (room_no) { 132 | if (this.ROOM[room_no].game) { 133 | this.ROOM[room_no].game.disconnect(sock); 134 | } 135 | else { 136 | if (this.ROOM[room_no].uids[0] == user.uid) { 137 | this.ROOM[room_no].exptime = exptime(sock); 138 | } 139 | else { 140 | this.ROOM[room_no].uids = this.ROOM[room_no].uids 141 | .filter(uid => uid != user.uid); 142 | delete this.USER[user.uid]; 143 | } 144 | this.send_room_info(room_no); 145 | } 146 | } 147 | else { 148 | delete this.USER[user.uid]; 149 | } 150 | this.status_log(); 151 | } 152 | 153 | room(sock, room_no, uid) { 154 | 155 | if (! room_no) this.create_room(sock); 156 | else if (! uid) this.enter_room(sock, room_no); 157 | else this.leave_room(sock, room_no, uid); 158 | } 159 | 160 | create_room(sock) { 161 | 162 | let user = get_user(sock); 163 | 164 | if (this.USER[user.uid].room_no) return; 165 | 166 | this.cleanup_room(); 167 | 168 | let room_no; 169 | const CODE = 'ABCDEFGHJKLMNPQRSTUVWXYZ', NUM = 10000; 170 | do { 171 | let n = (Math.random() * CODE.length * NUM) | 0; 172 | room_no = CODE[(n / NUM) | 0] + ('' + NUM + (n % NUM)).slice(-4); 173 | } while(this.ROOM[room_no]); 174 | this.ROOM[room_no] = { uids: [ user.uid ] }; 175 | this.USER[user.uid].room_no = room_no; 176 | this.send_room_info(room_no); 177 | this.status_log(); 178 | } 179 | 180 | enter_room(sock, room_no) { 181 | 182 | let user = get_user(sock); 183 | 184 | if (this.USER[user.uid].room_no) return; 185 | 186 | if (! this.ROOM[room_no]) { 187 | sock.emit('ERROR', `ルーム ${room_no} は存在しません`); 188 | } 189 | else if (this.ROOM[room_no].game) { 190 | sock.emit('ERROR', '既に対局中です'); 191 | } 192 | else if (this.ROOM[room_no].uids.length >= 4) { 193 | sock.emit('ERROR', '満室です'); 194 | } 195 | else { 196 | this.ROOM[room_no].uids.push(user.uid); 197 | this.USER[user.uid].room_no = room_no; 198 | 199 | this.send_room_info(room_no); 200 | this.status_log(); 201 | } 202 | } 203 | 204 | leave_room(sock, room_no, uid) { 205 | 206 | let user = get_user(sock); 207 | 208 | if (! this.USER[uid] || ! this.ROOM[room_no]) return; 209 | if (this.USER[uid].room_no != room_no) return; 210 | if (this.ROOM[room_no].game) return; 211 | 212 | if (uid == user.uid && this.ROOM[room_no].uids[0] == user.uid) { 213 | for (let uid of this.ROOM[room_no].uids) { 214 | delete this.USER[uid].room_no; 215 | this.USER[uid].sock.emit('HELLO', this.USER[uid].user); 216 | } 217 | delete this.ROOM[room_no]; 218 | } 219 | else if (uid == user.uid || this.ROOM[room_no].uids[0] == user.uid) { 220 | this.ROOM[room_no].uids 221 | = this.ROOM[room_no].uids.filter(u => u != uid); 222 | delete this.USER[uid].room_no; 223 | this.USER[uid].sock.emit('HELLO', this.USER[uid].user); 224 | this.send_room_info(room_no); 225 | } 226 | this.status_log(); 227 | } 228 | 229 | send_room_info(room_no) { 230 | 231 | for (let uid of this.ROOM[room_no].uids) { 232 | let sock = this.USER[uid].sock; 233 | if (! sock) continue; 234 | sock.emit('ROOM', { 235 | room_no: room_no, 236 | user: this.ROOM[room_no].uids.map(uid => 237 | Object.assign({}, this.USER[uid].user, 238 | { offline: ! this.USER[uid].sock })) 239 | }); 240 | } 241 | } 242 | 243 | cleanup_room() { 244 | for (let room_no of Object.keys(this.ROOM)) { 245 | let exptime = this.ROOM[room_no].exptime; 246 | if (exptime && exptime < new Date()) { 247 | for (let uid of this.ROOM[room_no].uids) { 248 | delete this.USER[uid].room_no; 249 | if (! this.USER[uid].sock) delete this.USER[uid]; 250 | } 251 | delete this.ROOM[room_no]; 252 | } 253 | } 254 | } 255 | 256 | get_socks(room_no) { 257 | let uids = []; 258 | for (let i = 0; i < 4; i++) { 259 | uids[i] = this.ROOM[room_no].uids[i]; 260 | } 261 | let socks = []; 262 | while (socks.length < 4) { 263 | let uid = uids.splice(Math.random()*uids.length, 1)[0]; 264 | socks.push(uid ? this.USER[uid].sock : null); 265 | } 266 | return socks; 267 | } 268 | 269 | start(sock, room_no, rule, timer) { 270 | 271 | let user = get_user(sock); 272 | 273 | if (! this.ROOM[room_no]) return; 274 | if (user.uid != this.ROOM[room_no].uids[0]) return; 275 | if (this.ROOM[room_no].game) return; 276 | 277 | const callback = (paipu)=>{ 278 | for (let uid of this.ROOM[room_no].uids) { 279 | if (! this.USER[uid].sock) delete this.USER[uid]; 280 | else delete this.USER[uid].room_no; 281 | } 282 | delete this.ROOM[room_no]; 283 | this.status_log(); 284 | }; 285 | this.ROOM[room_no].game = new Game(this.get_socks(room_no), callback, 286 | rule, null, timer); 287 | this.ROOM[room_no].game.speed = 2; 288 | this.ROOM[room_no].game.kaiju(); 289 | this.status_log(); 290 | } 291 | 292 | short_status() { 293 | let conn, room, game; 294 | try { 295 | conn = Object.keys(this.USER) 296 | .filter(uid => this.USER[uid].sock).length; 297 | room = 0, game = 0; 298 | for (let room_no of Object.keys(this.ROOM)) { 299 | if (this.ROOM[room_no].game) 300 | game += this.ROOM[room_no].uids 301 | .filter(uid => this.USER[uid].sock).length; 302 | else 303 | room += this.ROOM[room_no].uids 304 | .filter(uid => this.USER[uid].sock).length; 305 | } 306 | } 307 | catch(e) { 308 | console.error(e.stack); 309 | console.error(this.dump()); 310 | } 311 | return `接続: ${conn} / 待機: ${room} / 対局: ${game}`; 312 | } 313 | 314 | status_log() { 315 | console.log('**', this.short_status()); 316 | } 317 | 318 | status(refresh, all, debug) { 319 | 320 | function datestr(date) { 321 | return date.toLocaleString('sv'); 322 | } 323 | function timestr(time) { 324 | let day = (time / (24*60*60*1000))|0; 325 | time = new Date(time).toLocaleTimeString('sv', { timeZone: 'UTC'}); 326 | return day == 0 ? `${time}` 327 | : day < 7 ? `${day}日 ${time}` 328 | : `${day}日`; 329 | } 330 | 331 | const title = 'majiang-server status'; 332 | const room = print_room(this.USER, this.ROOM); 333 | 334 | let html = `${title}\n` 335 | + '\n' 337 | + style; 338 | if (refresh) 339 | html += `\n`; 340 | html += `

${title}

\n`; 341 | html += `
ver.${version}
\n`; 342 | 343 | let now = new Date(); 344 | html += `

現在: ${datestr(now)} / ` 345 | + `起動: ${datestr(this._start_date)} / ` 346 | + `稼働: ${timestr(now - this._start_date)}

\n`; 347 | 348 | if (debug != null) return html + `
${this.dump()}
`; 349 | 350 | html += `\n`; 351 | 352 | html += '\n'; 369 | 370 | return html; 371 | } 372 | 373 | dump() { 374 | let dump = '== ROOM ==\n'; 375 | for (let room_no of Object.keys(this.ROOM)) { 376 | dump += (this.ROOM[room_no].game ? ' * ' : ' - ') + room_no 377 | + ` [ ${this.ROOM[room_no].uids.map(uid=>uid.slice(-12)) 378 | .join(', ')} ] ` 379 | + (this.ROOM[room_no].exptime 380 | ? this.ROOM[room_no].exptime.toLocaleString('sv') 381 | : '') 382 | + '\n'; 383 | } 384 | dump += '-- USER --\n'; 385 | for (let uid of Object.keys(this.USER)) { 386 | dump += (this.USER[uid].sock ? ' * ' : ' - ') + uid.slice(-12) 387 | + ` ${this.USER[uid].user.name}` 388 | + ` ${this.USER[uid].room_no || ''}\n`; 389 | } 390 | return dump; 391 | } 392 | } 393 | 394 | module.exports = (io)=> new Lobby(io); 395 | -------------------------------------------------------------------------------- /lib/passport.js: -------------------------------------------------------------------------------- 1 | /* 2 | * passport 3 | */ 4 | 5 | "use strict"; 6 | 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | 10 | const passport = require('passport'); 11 | 12 | passport.serializeUser((user, done)=> done(null, user)); 13 | passport.deserializeUser((userstr, done)=> done(null, userstr)); 14 | 15 | const local = require('passport-local'); 16 | 17 | passport.use(new local.Strategy( 18 | { usernameField: 'name', 19 | passwordField: 'passwd' }, 20 | (name, passwd, done)=> done(null, { name: name }) 21 | )); 22 | 23 | module.exports = function(auth) { 24 | 25 | if (auth) { 26 | 27 | if (fs.existsSync(path.join(auth, 'hatena.json'))) { 28 | 29 | const hatena = require('passport-hatena-oauth'); 30 | 31 | passport.use(new hatena.Strategy( 32 | require(path.join(auth, 'hatena.json')), 33 | (token, tokenSecret, profile, done)=>{ 34 | let user = { 35 | uid: profile.id + '@hatena', 36 | name: profile.displayName, 37 | icon: profile.photos[0].value 38 | }; 39 | done(null, user); 40 | } 41 | )); 42 | } 43 | 44 | if (fs.existsSync(path.join(auth, 'google.json'))) { 45 | 46 | const google = require('passport-google-oauth20'); 47 | 48 | passport.use(new google.Strategy( 49 | require(path.join(auth, 'google.json')), 50 | (accessToken, refreshToken, profile, cb)=>{ 51 | let user = { 52 | uid: profile.id + '@google', 53 | name: profile.displayName, 54 | icon: profile.photos[0].value 55 | }; 56 | cb(null, user); 57 | } 58 | )); 59 | } 60 | } 61 | 62 | return passport; 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kobalab/majiang-server", 3 | "version": "1.4.4", 4 | "description": "麻雀サーバー", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "bin": { 9 | "majiang-server": "./bin/server.js", 10 | "majiang-bot": "./bin/client.js" 11 | }, 12 | "scripts": { 13 | "test": "mocha -u tdd", 14 | "test:cover": "nyc -r lcov -r text npm test", 15 | "start": "node bin/server.js" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/kobalab/majiang-server.git" 20 | }, 21 | "keywords": [ 22 | "麻雀", 23 | "電脳麻将" 24 | ], 25 | "author": "Satoshi Kobayashi", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/kobalab/majiang-server/issues" 29 | }, 30 | "homepage": "https://github.com/kobalab/majiang-server#readme", 31 | "dependencies": { 32 | "@kobalab/majiang-ai": "^1.0.13", 33 | "@kobalab/majiang-core": "^1.3.2", 34 | "express": "^4.21.2", 35 | "express-session": "^1.18.1", 36 | "passport": "^0.7.0", 37 | "passport-google-oauth20": "^2.0.0", 38 | "passport-hatena-oauth": "^1.0.0", 39 | "passport-local": "^1.0.0", 40 | "session-file-store": "^1.5.0", 41 | "socket.io": "^4.8.1", 42 | "socket.io-client": "^4.8.1", 43 | "yargs": "^17.7.2" 44 | }, 45 | "devDependencies": { 46 | "mocha": "^11.1.0", 47 | "nyc": "^17.1.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/lobby.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | const { rule } = require('@kobalab/majiang-core'); 4 | 5 | class Emitter { 6 | constructor() { 7 | this._callbacks = {}; 8 | this._emit_log = []; 9 | } 10 | on(type, callback) { 11 | this._callbacks[type] = this._callbacks[type] ?? []; 12 | this._callbacks[type].push(callback) 13 | } 14 | removeAllListeners(type) { 15 | delete this._callbacks[type]; 16 | } 17 | trigger(type, ...msg) { 18 | (this._callbacks[type] || []).forEach( 19 | callback => callback(...msg) 20 | ); 21 | } 22 | emit(type, ...msg) { 23 | this._emit_log.push([ type, ...msg ]); 24 | } 25 | emit_log(n = -1) { 26 | if (n < 0) n = this._emit_log.length + n; 27 | return this._emit_log[n]; 28 | } 29 | } 30 | 31 | function sessionID() { 32 | return Math.random().toString(16).slice(-12); 33 | } 34 | 35 | class Socket extends Emitter { 36 | constructor(user) { 37 | super(); 38 | this.request = {}; 39 | if (user) { 40 | this.request.user = user; 41 | this.request.sessionID = sessionID(); 42 | this.request.session = { cookie: { expires: new Date() } }; 43 | } 44 | } 45 | disconnect(flag) { 46 | this._dissconect = flag; 47 | } 48 | trigger(type, ...msg) { 49 | if (this._dissconect) return; 50 | super.trigger(type, ...msg); 51 | } 52 | emit(type, ...msg) { 53 | super.emit(type, ...msg); 54 | if (type == 'GAME' && msg[0].seq) { 55 | this.trigger('GAME', { seq: msg[0].seq }); 56 | } 57 | } 58 | } 59 | 60 | const io = new Emitter(); 61 | 62 | function connect(user) { 63 | let sock = new Socket(user); 64 | io.trigger('connection', sock); 65 | return sock; 66 | } 67 | 68 | console.log = ()=>{}; 69 | 70 | suite('Lobby', ()=>{ 71 | 72 | let lobby; 73 | test('モジュールが存在すること', ()=> assert.ok(require('../lib/lobby'))); 74 | test('モジュールが呼び出せること', ()=> 75 | assert.ok(lobby = require('../lib/lobby')(io))); 76 | 77 | suite('接続', ()=>{ 78 | test('非ログインユーザを拒否すること', ()=>{ 79 | const sock = connect(); 80 | assert.ok(sock._dissconect); 81 | }); 82 | test('ゲスト認証にuidを払い出すこと', ()=>{ 83 | const sock = connect({ name: 'ゲスト' }); 84 | let [ type, msg ] = sock.emit_log(); 85 | assert.equal(type, 'HELLO'); 86 | assert.equal(msg.name, 'ゲスト'); 87 | assert.ok(msg.uid); 88 | assert.deepEqual(lobby.USER[msg.uid].user, 89 | { uid: msg.uid, name: 'ゲスト' }); 90 | assert.ok(lobby.USER[msg.uid].sock); 91 | }); 92 | test('外部認証を許可すること', ()=>{ 93 | const user = { uid:'user@hatena', name:'はてな', icon:'icon.png' }; 94 | const sock = connect(user); 95 | let [ type, msg ] = sock.emit_log(); 96 | assert.equal(type, 'HELLO'); 97 | assert.deepEqual(msg, user); 98 | assert.deepEqual(lobby.USER['user@hatena'].user, user); 99 | assert.ok(lobby.USER['user@hatena'].sock); 100 | }); 101 | test('二重接続を拒否すること', ()=>{ 102 | let sock = connect({ name:'ゲスト' }); 103 | let [ type, msg ] = sock.emit_log(); 104 | sock = connect({ name:'二重接続', uid: msg.uid }); 105 | assert.ok(sock._dissconect); 106 | assert.deepEqual(lobby.USER[msg.uid].user, 107 | { uid: msg.uid, name: 'ゲスト' }); 108 | assert.ok(lobby.USER[msg.uid].sock); 109 | }); 110 | test('再接続を許可すること', ()=>{ 111 | const user = { uid:'user1@connect', name:'再接続', icon:'icon.png' }; 112 | let sock = connect(user); 113 | sock.trigger('disconnect'); 114 | sock = connect(user); 115 | let [ type, msg ] = sock.emit_log(); 116 | assert.equal(type, 'HELLO'); 117 | assert.deepEqual(msg, user); 118 | assert.deepEqual(lobby.USER['user1@connect'].user, user); 119 | assert.ok(lobby.USER['user1@connect'].sock); 120 | }); 121 | }); 122 | suite('ルーム', ()=>{ 123 | const user = [ 124 | { uid:'admin@room', name:'管理者', icon:'admin.png' }, 125 | { uid:'user1@room', name:'参加者1', icon:'user1.png' }, 126 | { uid:'user2@room', name:'参加者2', icon:'user2.png' }, 127 | { uid:'user3@room', name:'参加者3', icon:'user3.png' }, 128 | { uid:'user4@room', name:'参加者4', icon:'user4.png' }, 129 | ]; 130 | const sock = []; 131 | let room_no, type, msg; 132 | test('作成できること', ()=>{ 133 | sock[0] = connect(user[0]); 134 | sock[0].trigger('ROOM'); 135 | [ type, msg ] = sock[0].emit_log(); 136 | assert.equal(type, 'ROOM'); 137 | assert.ok(msg.room_no); 138 | assert.deepEqual(msg.user, 139 | [ Object.assign({}, user[0], { offline: false })]); 140 | room_no = msg.room_no; 141 | assert.deepEqual(lobby.ROOM[room_no].uids, [ 'admin@room' ]); 142 | assert.equal(lobby.USER['admin@room'].room_no, room_no); 143 | }); 144 | test('入室できること', ()=>{ 145 | sock[1] = connect(user[1]); 146 | sock[1].trigger('ROOM', room_no); 147 | [ type, msg ] = sock[1].emit_log(); 148 | assert.equal(type, 'ROOM'); 149 | assert.equal(msg.room_no, room_no); 150 | assert.deepEqual(msg.user[1], 151 | Object.assign({}, user[1], { offline: false })); 152 | assert.deepEqual(lobby.ROOM[room_no].uids, 153 | [ 'admin@room','user1@room' ]); 154 | assert.equal(lobby.USER['user1@room'].room_no, room_no); 155 | }); 156 | test('管理者が切断してもルームが残ること', ()=>{ 157 | sock[0].trigger('disconnect'); 158 | [ type, msg ] = sock[1].emit_log(); 159 | assert.equal(type, 'ROOM'); 160 | assert.equal(msg.room_no, room_no); 161 | assert.deepEqual(msg.user[0], 162 | Object.assign({}, user[0], { offline: true })); 163 | assert.deepEqual(lobby.ROOM[room_no].uids, 164 | [ 'admin@room','user1@room' ]); 165 | assert.ok(! lobby.ROOM[room_no].exptime); 166 | assert.equal(lobby.USER['admin@room'].room_no, room_no); 167 | assert.ok(! lobby.USER['admin@room'].sock); 168 | }); 169 | test('参加者が切断すると退室すること', ()=>{ 170 | sock[2] = connect(user[2]); 171 | sock[2].trigger('ROOM', room_no); 172 | assert.deepEqual(lobby.ROOM[room_no].uids, 173 | [ 'admin@room','user1@room','user2@room' ]); 174 | sock[2].trigger('disconnect'); 175 | [ type, msg ] = sock[1].emit_log(); 176 | assert.equal(type, 'ROOM'); 177 | assert.equal(msg.room_no, room_no); 178 | assert.ok(! msg.user.find(u=>u.uid == 'user2@room')); 179 | assert.deepEqual(lobby.ROOM[room_no].uids, 180 | [ 'admin@room','user1@room' ]); 181 | assert.ok(! lobby.USER['user2@room']); 182 | }); 183 | test('管理者が再接続するとルームに戻ること', ()=>{ 184 | sock[0] = connect(user[0]); 185 | [ type, msg ] = sock[1].emit_log(); 186 | assert.equal(type, 'ROOM'); 187 | assert.ok(msg.room_no); 188 | assert.deepEqual(msg.user[0], 189 | Object.assign({}, user[0], { offline: false })); 190 | assert.ok(! lobby.ROOM[room_no].exptime); 191 | assert.ok(lobby.USER['admin@room'].sock); 192 | }); 193 | test('参加者が再接続してもルームに戻らないこと', ()=>{ 194 | sock[2] = connect(user[2]); 195 | [ type, msg ] = sock[2].emit_log(); 196 | assert.equal(type, 'HELLO'); 197 | assert.deepEqual(lobby.ROOM[room_no].uids, 198 | [ 'admin@room','user1@room' ]); 199 | assert.ok(! lobby.USER['user2@room'].room_no); 200 | }); 201 | test('管理者は参加者を強制退室できること', ()=>{ 202 | sock[2].trigger('ROOM', room_no); 203 | assert.deepEqual(lobby.ROOM[room_no].uids, 204 | [ 'admin@room','user1@room','user2@room' ]); 205 | sock[0].trigger('ROOM', room_no, 'user2@room'); 206 | [ type, msg ] = sock[2].emit_log(); 207 | assert.equal(type, 'HELLO'); 208 | [ type, msg ] = sock[1].emit_log(); 209 | assert.equal(type, 'ROOM'); 210 | assert.equal(msg.room_no, room_no); 211 | assert.ok(! msg.user.find(u=>u.uid == 'user2@room')); 212 | assert.deepEqual(lobby.ROOM[room_no].uids, 213 | [ 'admin@room','user1@room' ]); 214 | assert.ok(! lobby.USER['user2@room'].room_no); 215 | }); 216 | test('参加者は自ら退室できること', ()=>{ 217 | sock[2].trigger('ROOM', room_no); 218 | assert.deepEqual(lobby.ROOM[room_no].uids, 219 | [ 'admin@room','user1@room','user2@room' ]); 220 | sock[2].trigger('ROOM', room_no, 'user2@room'); 221 | [ type, msg ] = sock[2].emit_log(); 222 | assert.equal(type, 'HELLO'); 223 | [ type, msg ] = sock[1].emit_log(); 224 | assert.equal(type, 'ROOM'); 225 | assert.equal(msg.room_no, room_no); 226 | assert.ok(! msg.user.find(u=>u.uid == 'user2@room')); 227 | assert.deepEqual(lobby.ROOM[room_no].uids, 228 | [ 'admin@room','user1@room' ]); 229 | assert.ok(! lobby.USER['user2@room'].room_no); 230 | }); 231 | test('管理者が退室するとルームがなくなること', ()=>{ 232 | sock[0].trigger('ROOM', room_no, 'admin@room'); 233 | [ type, msg ] = sock[1].emit_log(); 234 | assert.equal(type, 'HELLO'); 235 | assert.ok(! lobby.ROOM[room_no]); 236 | }); 237 | test('存在しないルームに入室できないこと',()=>{ 238 | sock[1].trigger('ROOM', 'badroom'); 239 | [ type, msg ] = sock[1].emit_log(); 240 | assert.equal(type, 'ERROR'); 241 | }); 242 | test('満室のルームに入室できないこと', ()=>{ 243 | for (let i = 0; i < 5; i++) { 244 | if (! sock[i]) sock[i] = connect(user[i]); 245 | if (i == 0) { 246 | sock[i].trigger('ROOM'); 247 | [ type, msg ] = sock[i].emit_log(); 248 | assert.equal(type, 'ROOM'); 249 | room_no = msg.room_no; 250 | } 251 | else { 252 | sock[i].trigger('ROOM', room_no); 253 | [ type, msg ] = sock[i].emit_log(); 254 | if (i < 4) assert.equal(type, 'ROOM'); 255 | else assert.equal(type, 'ERROR'); 256 | } 257 | } 258 | assert.equal(lobby.ROOM[room_no].uids.length, 4); 259 | }); 260 | test('入室済みの場合、ルームを作成できないこと', ()=>{ 261 | sock[1].trigger('ROOM'); 262 | assert.equal(lobby.USER['user1@room'].room_no, room_no); 263 | }); 264 | test('入室済みの場合、他のルームに入室できないこと', ()=>{ 265 | sock[4].trigger('ROOM'); 266 | let new_room_no = sock[4].emit_log()[1].room_no; 267 | sock[2].trigger('ROOM', new_room_no); 268 | assert.equal(lobby.USER['user2@room'].room_no, room_no); 269 | }); 270 | test('参加者による参加者の強制退室', ()=>{ 271 | sock[2].trigger('ROOM', room_no, 'user3@room'); 272 | [ type, msg ] = sock[3].emit_log(); 273 | assert.notEqual(type, 'HELLO'); 274 | }); 275 | test('他のルームの参加者の強制退室', ()=>{ 276 | sock[0].trigger('ROOM', room_no, 'user4@room'); 277 | [ type, msg ] = sock[4].emit_log(); 278 | assert.notEqual(type, 'HELLO'); 279 | }); 280 | test('存在しない参加者の強制退室', ()=>{ 281 | sock[0].trigger('ROOM', room_no, 'baduser'); 282 | }); 283 | test('存在しないルームの強制退室', ()=>{ 284 | sock[0].trigger('ROOM', 'badroom', 'admin@room'); 285 | }); 286 | test('管理者不在のルームを削除すること', ()=>{ 287 | sock[0].trigger('disconnect'); 288 | sock[5] = connect({ name:'ゲスト'}); 289 | sock[5].trigger('ROOM'); 290 | let new_room_no = lobby.USER[sock[5].request.user.uid].room_no; 291 | sock[6] = connect({ name:'ゲスト'}); 292 | sock[6].trigger('ROOM', new_room_no); 293 | sock[5].trigger('disconnect'); 294 | lobby.cleanup_room(); 295 | assert.ok(lobby.ROOM[new_room_no].exptime); 296 | lobby.ROOM[new_room_no].exptime = -1; 297 | lobby.cleanup_room(); 298 | assert.ok(! lobby.USER[sock[5].request.user.uid]); 299 | assert.ok(! lobby.USER[sock[6].request.user.uid].room_no); 300 | assert.ok(! lobby.ROOM[new_room_no]); 301 | }); 302 | }); 303 | suite('ゲーム', ()=>{ 304 | const user = [ 305 | { uid:'admin@game', name:'管理者', icon:'admin.png' }, 306 | { uid:'user1@game', name:'参加者1', icon:'user1.png' }, 307 | { uid:'user2@game', name:'参加者2', icon:'user2.png' }, 308 | { uid:'user3@game', name:'参加者3', icon:'user3.png' }, 309 | { uid:'user4@game', name:'参加者4', icon:'user4.png' }, 310 | ]; 311 | const sock = []; 312 | let room_no, type, msg; 313 | test('対局を開始できること', ()=>{ 314 | for (let i = 0; i < 3; i++) { 315 | sock[i] = connect(user[i]); 316 | if (i == 0) { sock[i].trigger('ROOM'); 317 | [ type, msg ] = sock[i].emit_log(); 318 | room_no = msg.room_no; } 319 | else { sock[i].trigger('ROOM', room_no); } 320 | } 321 | sock[0].trigger('START', room_no, rule({'場数': 1}), [ 5, 10, 15 ]); 322 | for (let i = 0; i < 3; i++) { 323 | assert.equal(sock[i]._emit_log. 324 | filter(log => log[0] == 'START').length, 1); 325 | [ type, msg ] = sock[i].emit_log(); 326 | assert.equal(type, 'GAME'); 327 | assert.ok(msg.kaiju); 328 | assert.deepEqual(msg.timer, [ 15 ]) 329 | assert.equal(msg.kaiju.rule['場数'], 1); 330 | } 331 | assert.ok(lobby.ROOM[room_no].game); 332 | }); 333 | test('対局開始後は入室できないこと', ()=>{ 334 | sock[3] = connect(user[3]); 335 | sock[3].trigger('ROOM', room_no); 336 | [ type, msg ] = sock[3].emit_log(); 337 | assert.equal(type, 'ERROR'); 338 | }); 339 | test('対局開始後は退室できないこと', ()=>{ 340 | sock[2].trigger('ROOM', room_no, 'user2@game'); 341 | [ type, msg ] = sock[2].emit_log(); 342 | assert.notEqual(type, 'HELLO'); 343 | assert.equal(lobby.USER['user2@game'].room_no, room_no); 344 | assert.equal(lobby.ROOM[room_no].uids.length, 3); 345 | }); 346 | test('対局開始後に重複して開始できないこと', ()=>{ 347 | sock[0].trigger('START', room_no, rule()); 348 | assert.equal(sock[0]._emit_log. 349 | filter(log => log[0] == 'START').length, 1); 350 | }); 351 | test('切断した対局者を通知すること', ()=>{ 352 | sock[1].trigger('disconnect'); 353 | [ type, msg ] = sock[0].emit_log(); 354 | assert.equal(type, 'GAME'); 355 | assert.ok(msg.players); 356 | assert.ok(! msg.players.find(u=>u && u.uid == 'user1@game')); 357 | assert.equal(lobby.USER['user1@game'].room_no, room_no); 358 | assert.ok(! lobby.USER['user1@game'].sock); 359 | }); 360 | test('再接続で対局が再開できること', ()=>{ 361 | sock[1] = connect(user[1]); 362 | assert.equal(lobby.USER['user1@game'].room_no, room_no); 363 | assert.ok(lobby.USER['user1@game'].sock); 364 | [ type, msg ] = sock[0].emit_log(); 365 | assert.equal(type, 'GAME'); 366 | assert.ok(msg.players); 367 | assert.ok(msg.players.find(u=>u && u.uid == 'user1@game')); 368 | [ type, msg ] = sock[1].emit_log(1); 369 | assert.equal(type, 'START'); 370 | [ type, msg ] = sock[1].emit_log(2); 371 | assert.equal(type, 'GAME'); 372 | assert.ok(msg.kaiju && msg.kaiju.log); 373 | [ type, msg ] = sock[1].emit_log(3); 374 | assert.equal(type, 'GAME'); 375 | assert.ok(msg.players); 376 | }); 377 | test('全員の切断で対局が終了すること', (done)=>{ 378 | assert.ok(lobby.ROOM[room_no].game); 379 | const callback = lobby.ROOM[room_no].game._callback; 380 | lobby.ROOM[room_no].game._callback = (paipu)=>{ 381 | callback(paipu); 382 | sock[0] = connect(user[0]); 383 | [ type, msg ] = sock[0].emit_log(); 384 | assert.equal(type, 'HELLO'); 385 | assert.ok(! lobby.ROOM[room_no]); 386 | done(); 387 | }; 388 | sock.forEach(s => s.trigger('disconnect')); 389 | }); 390 | test('再接続した管理者が対局を開始できること', (done)=>{ 391 | for (let i = 0; i < 4; i++) { 392 | if (i == 0) { sock[i].trigger('ROOM'); 393 | [ type, msg ] = sock[i].emit_log(); 394 | room_no = msg.room_no; } 395 | else { sock[i] = connect(user[i]); 396 | sock[i].trigger('ROOM', room_no); } 397 | } 398 | sock[0].trigger('disconnect'); 399 | sock[0] = connect(user[0]); 400 | sock[0].trigger('START', room_no, rule()); 401 | for (let i = 0; i < 4; i++) { 402 | assert.equal(sock[i]._emit_log. 403 | filter(log => log[0] == 'START').length, 1); 404 | [ type, msg ] = sock[i].emit_log(); 405 | assert.equal(type, 'GAME'); 406 | assert.ok(msg.kaiju); 407 | } 408 | assert.ok(lobby.ROOM[room_no].game); 409 | const callback = lobby.ROOM[room_no].game._callback; 410 | lobby.ROOM[room_no].game._callback = (paipu)=>{ 411 | callback(paipu); 412 | done(); 413 | }; 414 | sock.forEach(s => s.trigger('disconnect')); 415 | }); 416 | test('参加者が対局を開始できないこと', ()=>{ 417 | sock[0] = connect(user[0]); 418 | sock[0].trigger('ROOM'); 419 | [ type, msg ] = sock[0].emit_log(); 420 | room_no = msg.room_no; 421 | sock[1] = connect(user[1]); 422 | sock[1].trigger('ROOM', room_no); 423 | sock[1].trigger('START', room_no); 424 | assert.ok(! sock[1]._emit_log.find(log => log[0] == 'START')); 425 | assert.ok(! lobby.ROOM[room_no].game); 426 | }); 427 | test('他のルームの対局を開始できないこと', ()=>{ 428 | sock[4] = connect(user[4]); 429 | sock[4].trigger('ROOM'); 430 | let new_room_no = sock[4].emit_log()[1].room_no; 431 | sock[0].trigger('START', new_room_no); 432 | assert.ok(! sock[4]._emit_log.find(log => log[0] == 'START')); 433 | assert.ok(! lobby.ROOM[new_room_no].game); 434 | }); 435 | test('存在しないルームの対局開始', ()=>{ 436 | sock[0].trigger('START', 'badroom'); 437 | }); 438 | test('対局が終了すること', (done)=>{ 439 | for (let i = 0; i < 4; i++) { 440 | if (i == 0) { sock[i].trigger('ROOM'); 441 | [ type, msg ] = sock[i].emit_log(); 442 | room_no = msg.room_no; } 443 | else { sock[i] = connect(user[i]); 444 | sock[i].trigger('ROOM', room_no); } 445 | } 446 | sock[0].trigger('START', room_no, 447 | rule({ '場数': 1, "連荘方式": 0, "延長戦方式": 0 })); 448 | lobby.ROOM[room_no].game.speed = 0; 449 | const callback = lobby.ROOM[room_no].game._callback; 450 | lobby.ROOM[room_no].game._callback = (paipu)=>{ 451 | callback(paipu); 452 | assert.ok(! lobby.ROOM[room_no]); 453 | done(); 454 | }; 455 | }); 456 | }); 457 | suite('ステータス表示', (done)=>{ 458 | const user = [ 459 | { uid:'user0@status', name:'ユーザ0', icon:'user0.png' }, 460 | { uid:'user1@status', name:'ユーザ1', icon:'user1.png' }, 461 | { uid:'user2@status', name:'ユーザ2', icon:'user2.png' }, 462 | { uid:'user3@status', name:'ユーザ3', icon:'user3.png' }, 463 | { name:'ユーザ5' }, 464 | ]; 465 | const sock = []; 466 | let room_no, type, msg; 467 | test('ステータスが表示できること', (done)=>{ 468 | 469 | for (let i = 0; i < user.length; i++) { 470 | sock[i] = connect(user[i]); 471 | } 472 | 473 | sock[0].trigger('ROOM'); 474 | [ type, msg ] = sock[0].emit_log(); 475 | room_no = msg.room_no; 476 | sock[1].trigger('ROOM', room_no); 477 | sock[0].trigger('START', room_no); 478 | lobby.ROOM[room_no].game._callback = done; 479 | sock[1].trigger('disconnect'); 480 | 481 | sock[2].trigger('ROOM'); 482 | [ type, msg ] = sock[2].emit_log(); 483 | room_no = msg.room_no; 484 | sock[3].trigger('ROOM', room_no); 485 | sock[2].trigger('disconnect'); 486 | 487 | sock[4].trigger('ROOM'); 488 | sock[4].trigger('disconnect'); 489 | 490 | assert.ok(lobby.status()); 491 | assert.ok(lobby.status(15)); 492 | assert.ok(lobby.status(15, '')); 493 | assert.ok(lobby.status(15, '', '')); 494 | 495 | lobby._start_date -= 1000*60*60*24*6; 496 | assert.ok(lobby.status()); 497 | 498 | lobby._start_date -= 1000*60*60*24; 499 | assert.ok(lobby.status()); 500 | 501 | sock[0].trigger('disconnect'); 502 | }); 503 | }); 504 | suite('例外処理', ()=>{ 505 | const CONSOLE_ERROR = console.error(); 506 | suiteSetup(()=>{ 507 | console.error = ()=>{}; 508 | }); 509 | test('ログ出力時の例外を捕捉すること', ()=>{ 510 | let sock = connect({ name: 'ゲスト' }); 511 | let [ type, msg ] = sock.emit_log(); 512 | sock.trigger('ROOM'); 513 | delete lobby.USER[msg.uid]; 514 | assert.ok(lobby.short_status()); 515 | }); 516 | suiteTeardown(()=>{ 517 | console.error = CONSOLE_ERROR; 518 | }); 519 | }); 520 | }); 521 | --------------------------------------------------------------------------------