├── .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'
60 | + ROOM[room_no].uids
61 | .map(uid => `- ${user(uid)}
`)
62 | .join('\n')
63 | + '
\n';
64 | }
65 | else {
66 | return '(接続中)\n'
67 | + Object.keys(USER).filter(uid => ! USER[uid].room_no)
68 | .map(uid => `- ${user(uid)}
`)
69 | .join('\n')
70 | + '
\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';
353 | for (let room_no of Object.keys(this.ROOM)
354 | .filter(r => this.ROOM[r].game))
355 | {
356 | html += `- ${room(room_no)}
\n`;
357 | }
358 | for (let room_no of Object.keys(this.ROOM)
359 | .filter(r => ! this.ROOM[r].game))
360 | {
361 | if (all != null || this.ROOM[room_no].uids
362 | .filter(uid => this.USER[uid].sock).length)
363 | {
364 | html += `- ${room(room_no)}
\n`;
365 | }
366 | }
367 | html += `- ${room()}
\n`;
368 | 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 |
--------------------------------------------------------------------------------