@kobalab/mjai-test-server 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ChangeLog.md ADDED
@@ -0,0 +1,7 @@
1
+ ### v0.1.1 / 2026-09-23
2
+
3
+ - npm に登録
4
+
5
+ ## v0.1.0 / 2026-09-23
6
+
7
+ - 初版リリース
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 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.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # mjai-test-server
2
+
3
+ Mjaiボット対戦サーバー
4
+
5
+ 評価のために [Mjai](https://gimite.net/pukiwiki/index.php?Mjai%20麻雀AI対戦サーバ) ボットを対戦させるサーバー。
6
+ 対戦方法には [デュプリケート対局](https://blog.kobalab.net/entry/2020/12/19/075529) を選択できる。
7
+ 対戦結果は [牌譜](https://github.com/kobalab/majiang-core/wiki/牌譜) に保存される。
8
+
9
+ ## インストール
10
+ ```bash
11
+ $ npm i -g @kobalab/mjai-test-server
12
+ ```
13
+
14
+ ## 使用方法
15
+
16
+ ### mjai-test-server [ *options...* ] *mjai-bot1* *mjai-bot2*
17
+
18
+ **bjai-bot1** で指定した3体のボットと **mjai-bot2** で指定したボットと対戦させます。
19
+
20
+ #### --input, -i
21
+ デュプリケート対局用の牌山を指定します。省略した場合はランダムな牌山で自動対局します。
22
+
23
+ #### --output, -o
24
+ 指定されたファイルに牌譜を出力します。
25
+
26
+ #### --times, -t
27
+ 対局数を指定します。省略時は指定された牌山内の対局数にしたがいます。牌山も指定がない場合は1戦だけ対局します。
28
+
29
+ #### --skip, -s
30
+ 指定された数分牌山をスキップします。特定の牌山でだけ対局させたいときに便利です。
31
+
32
+ #### --rule, -r
33
+ JSONファイルもしくはJSON形式の文字列で [ルール](https://github.com/kobalab/majiang-core/wiki/ルール) を変更します。
34
+ **--input** で牌山を指定した場合、赤牌の枚数は牌山にしたがいます。
35
+
36
+ #### --verbose, -v
37
+ Mjaiプロトコルの通信を表示します。
38
+
39
+ ## ライセンス
40
+ [MIT](https://github.com/kobalab/mjai-test-server/blob/master/LICENSE)
41
+
42
+ ## 作者
43
+ [Satoshi Kobayashi](https://github.com/kobalab)
package/bin/server.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const Majiang = require('@kobalab/majiang-core');
6
+
7
+ const fs = require('fs');
8
+ const net = require('net');
9
+ const zlib = require('zlib');
10
+ const { execFile } = require('child_process');
11
+
12
+ const Game = require('../lib/game');
13
+ const Player = require('../lib/player');
14
+
15
+ function get_shan(filename) {
16
+ if (! filename) return;
17
+ return JSON.parse(zlib.gunzipSync(fs.readFileSync(filename)).toString());
18
+ }
19
+
20
+ function get_rule(filename = '{}') {
21
+ if (filename.match(/\{.*\}/)) {
22
+ return Majiang.rule(JSON.parse(filename));
23
+ }
24
+ return Majiang.rule(JSON.parse(fs.readFileSync(filename)));
25
+ }
26
+
27
+ function make_player(bot, callback) {
28
+ const server = net.createServer((sock)=>{
29
+ server.close();
30
+ callback(sock);
31
+ }).listen(()=>{
32
+ const port = server.address().port;
33
+ execFile(bot, [`mjsonp://127.0.0.1:${port}/default`])
34
+ .on('error', (err)=>{ throw err });
35
+ });
36
+ }
37
+
38
+ function player_name(base, name) {
39
+ for (let id = 0; id < 4; id++) {
40
+ if (base[id]) base[id] += ` [${name[id]}]`;
41
+ else base[id] = name[id];
42
+ base[id] = base[id].replace(/mjai\-/,'');
43
+ }
44
+ return base;
45
+ }
46
+
47
+ const argv = require('yargs')
48
+ .usage('Usage: $0 mjai-bot mjai-bot')
49
+ .option('times', { alias: 't', description: '試行回数' } )
50
+ .option('input', { alias: 'i', description: '入力ファイル(牌山)' } )
51
+ .option('output', { alias: 'o', description: '出力ファイル(牌譜)' } )
52
+ .option('skip', { alias: 's', description: '指定した数の牌山をスキップ' } )
53
+ .option('rule', { alias: 'r', description: 'ルール' })
54
+ .option('verbose', { alias: 'v', boolean: true })
55
+ .demandCommand(2)
56
+ .argv;
57
+
58
+ const script = get_shan(argv.input) || [];
59
+ for (let i = 0; i < (argv.skip || 0); i++) script.shift()
60
+
61
+ const rule = get_rule(argv.rule);
62
+
63
+ let times = argv.times || script && script.length || 1;
64
+
65
+ const bots = [ argv._[1], argv._[0], argv._[0], argv._[0] ];
66
+ let players = [];
67
+
68
+ const logs = [];
69
+
70
+ console.log(`[${times}]`, new Date().toLocaleTimeString());
71
+
72
+ function start_game() {
73
+ players = [];
74
+ let s = script.shift();
75
+ for (let id = 0; id < 4; id++) {
76
+ make_player(bots[id], (sock)=>{
77
+ players[id] = new Player(sock);
78
+ if (players.filter(s => s).length == 4) {
79
+ players[0].debug = argv.verbose;
80
+ const game = s ? new Game(players, end_game, rule).script(s)
81
+ : new Majiang.Game(players, end_game, rule);
82
+ game.model.player = player_name(game.model.player, bots);
83
+ game.model.title += ` #${logs.length + (argv.skip || 0)}`;
84
+ game.speed = 0;
85
+ game.kaiju();
86
+ }
87
+ });
88
+ }
89
+ }
90
+
91
+ function end_game(paipu) {
92
+ for (let player of players) {
93
+ player._sock.destroy();
94
+ }
95
+ console.log(`[${--times}]`, new Date().toLocaleTimeString(),
96
+ paipu.rank[0], paipu.point[0]);
97
+ if (argv.output) {
98
+ logs.push(paipu);
99
+ fs.writeFileSync(argv.output, JSON.stringify(logs), 'utf-8');
100
+ }
101
+ if (times > 0) start_game();
102
+ }
103
+
104
+ start_game();
package/lib/game.js ADDED
@@ -0,0 +1,38 @@
1
+ /*
2
+ * デュプリケート対戦
3
+ */
4
+ "use strict";
5
+
6
+ const Majiang = require('@kobalab/majiang-core');
7
+ const Shan = require('./shan');
8
+
9
+ module.exports = class Game extends Majiang.Game {
10
+
11
+ script(script) {
12
+ this._qijia = script.qijia;
13
+ this._shan = [];
14
+ for (let i = 0; i < script.shan.length; i++) {
15
+ let j = i % 4;
16
+ if (! this._shan[j]) this._shan[j] = [];
17
+ this._shan[j].push(script.shan[i]);
18
+ }
19
+ return this;
20
+ }
21
+ kaiju() {
22
+ super.kaiju(this._qijia);
23
+ }
24
+ qipai() {
25
+ let pai;
26
+ if (this._model.zhuangfeng % 2 == 0)
27
+ pai = this._shan[this._model.jushu].shift();
28
+ else pai = this._shan[this._model.jushu].pop();
29
+ if (! pai) console.log('***',
30
+ this._model.zhuangfeng, this._model.jushu);
31
+ super.qipai(pai && new Shan(pai, this._rule));
32
+ }
33
+ zimo() {
34
+ if (this._model.shan.lunban)
35
+ this._model.shan.lunban(this._model.lunban);
36
+ super.zimo();
37
+ }
38
+ }
package/lib/player.js ADDED
@@ -0,0 +1,307 @@
1
+ /*
2
+ * game
3
+ */
4
+ "use strict";
5
+
6
+ const Majiang = require('@kobalab/majiang-core');
7
+
8
+ const readline = require('readline');
9
+ const util = require('util');
10
+
11
+ function hai(s, n) {
12
+ return s == 'z'? ['','E','S','W','N','P','F','C'][+n]
13
+ : (+n||5) + s + (+n ? '' : 'r');
14
+ }
15
+
16
+ function tehai(paistr) {
17
+ paistr = paistr.replace(/,.*$/,'');
18
+ let bingpai = [];
19
+ for (let suitstr of paistr.match(/[mpsz]\d+/g)) {
20
+ let s = suitstr[0];
21
+ for (let n of suitstr.match(/\d/g)) {
22
+ bingpai.push(hai(s, n));
23
+ }
24
+ }
25
+ return bingpai;
26
+ }
27
+
28
+ function pai(p) {
29
+ if (p == '?') return '';
30
+ if (p.length == 1) return 'z' + { E:1, S:2, W:3, N:4, P:5, F:6, C:7 }[p];
31
+ let n = + p[0], s = p[1];
32
+ return s + (p[2] == 'r' ? 0 : n);
33
+ }
34
+
35
+ function mianzi(l, t, ...p) {
36
+ let d = ['','+','=','-'][(4 + t - l) % 4];
37
+ return Majiang.Shoupai.valid_mianzi(
38
+ p.map(p => pai(p)).join('').replace(/(?<=\d)[mpsz]/g,'') + d);
39
+ }
40
+
41
+
42
+ module.exports = class Player {
43
+
44
+ constructor(sock) {
45
+ this._sock = sock;
46
+ this._line = readline.createInterface(sock);
47
+ this._board = new Majiang.Board();
48
+ };
49
+
50
+ send(req) {
51
+ if (this.debug) console.log('<-', util.inspect(req,
52
+ { depth: null,
53
+ colors: process.stdout.isTTY }));
54
+ this._sock.write(JSON.stringify(req) + '\n');
55
+ }
56
+
57
+ recv() {
58
+ return new Promise(resolve =>{
59
+ this._line.once('line', (data)=>{
60
+ let res = JSON.parse(data);
61
+ if (this.debug) console.log('->', util.inspect(res,
62
+ { depth: null,
63
+ colors: process.stdout.isTTY }));
64
+ resolve(res);
65
+ });
66
+ });
67
+ }
68
+
69
+ async action(msg, callback) {
70
+
71
+ const board = this._board;
72
+
73
+ if (msg.kaiju) {
74
+ this._id = msg.kaiju.id;
75
+ }
76
+
77
+ if (msg.dapai && msg.dapai.p.slice(-1) == '*' && this._lizhi == null) {
78
+ this._lizhi = board.player_id[msg.dapai.l];
79
+ this.send({ type: 'reach', actor: this._lizhi });
80
+ await this.recv();
81
+ }
82
+ else if (this._lizhi != null && (msg.zimo || msg.fulou)) {
83
+ let deltas = [], scores = [];
84
+ for (let id = 0; id < 4; id++) {
85
+ deltas[id] = id == this._lizhi ? -1000 : 0;
86
+ scores[id] = board.defen[id];
87
+ }
88
+ this.send({ type: 'reach_accepted', actor: this._lizhi,
89
+ deltas: deltas, scores: scores });
90
+ await this.recv();
91
+ this._lizhi = null;
92
+ }
93
+
94
+ let req;
95
+ if (msg.kaiju) {
96
+ board.kaiju(msg.kaiju);
97
+ req = { type:'hello', protocol:'mjsonp', protocol_version: 3 };
98
+ this.send(req);
99
+ await this.recv();
100
+ req = {
101
+ type: 'start_game',
102
+ id: msg.kaiju.id,
103
+ names: msg.kaiju.player
104
+ };
105
+ }
106
+ else if (msg.qipai) {
107
+ board.qipai(msg.qipai);
108
+ let { zhuangfeng, jushu, changbang, lizhibang,
109
+ baopai, shoupai } = msg.qipai;
110
+ req = {
111
+ type: 'start_kyoku',
112
+ bakaze: ['E','S','W','N'][zhuangfeng],
113
+ kyoku: jushu + 1,
114
+ honba: changbang,
115
+ kyotaku: lizhibang,
116
+ oya: (board.qijia + jushu) % 4,
117
+ dora_marker: hai(...baopai),
118
+ tehais: []
119
+ };
120
+ for (let l = 0; l < 4; l++) {
121
+ let id = board.player_id[l];
122
+ req.tehais[id] = shoupai[l] ? tehai(shoupai[l])
123
+ : Array(13).fill('?')
124
+ }
125
+ this._lizhi = null;
126
+ this._peng = [];
127
+ }
128
+ else if (msg.zimo) {
129
+ board.zimo(msg.zimo);
130
+ let { l, p } = msg.zimo;
131
+ req = {
132
+ type: 'tsumo',
133
+ actor: board.player_id[l],
134
+ pai: p ? hai(...p) : '?'
135
+ };
136
+ }
137
+ else if (msg.dapai) {
138
+ board.dapai(msg.dapai);
139
+ let { l, p } = msg.dapai;
140
+ req = {
141
+ type: 'dahai',
142
+ actor: board.player_id[l],
143
+ pai: p ? hai(...p) : '?',
144
+ tsumogiri: p[2] == '_'
145
+ };
146
+ }
147
+ else if (msg.fulou) {
148
+ board.fulou(msg.fulou);
149
+ let { l, m } = msg.fulou;
150
+ let s = m[0];
151
+ let d = { '+': 1, '=': 2, '-': 3 }[m.match(/[\+\=\-]/)];
152
+ req = {
153
+ type: ( m.match(/\d{4}/) ? 'daiminkan'
154
+ : m.replace(/0/,'5').match(/(\d)\1/) ? 'pon'
155
+ : 'chi' ),
156
+ actor: board.player_id[l],
157
+ target: board.player_id[(l + d) % 4],
158
+ pai: hai(s, m.match(/\d(?=[\+\=\-])/)),
159
+ consumed: m.match(/\d(?![\+\=\-])/g).map(n => hai(s, n))
160
+ };
161
+ }
162
+ else if (msg.gang) {
163
+ board.gang(msg.gang);
164
+ let { l, m } = msg.gang;
165
+ let s = m[0];
166
+ if (m.match(/\d{4}/)) {
167
+ req = {
168
+ type: 'ankan',
169
+ actor: board.player_id[l],
170
+ consumed: m.match(/\d(?![\+\=\-])/g).map(n => hai(s, n))
171
+ };
172
+ }
173
+ else {
174
+ let d = { '+': 1, '=': 2, '-': 3 }[m.match(/[\+\=\-]/)];
175
+ req = {
176
+ type: 'kakan',
177
+ actor: board.player_id[l],
178
+ pai: hai(s, m.match(/(?<=[\+\=\-])\d/)),
179
+ consumed: m.match(/(?<![\+\=\-])\d/g).map(n => hai(s, n))
180
+ };
181
+ }
182
+ }
183
+ else if (msg.gangzimo) {
184
+ board.zimo(msg.gangzimo);
185
+ let { l, p } = msg.gangzimo;
186
+ req = {
187
+ type: 'tsumo',
188
+ actor: board.player_id[l],
189
+ pai: p ? hai(...p) : '?'
190
+ };
191
+ }
192
+ else if (msg.kaigang) {
193
+ board.kaigang(msg.kaigang);
194
+ let { baopai } = msg.kaigang;
195
+ req = {
196
+ type: 'dora',
197
+ dora_marker: hai(...baopai)
198
+ };
199
+ }
200
+ else if (msg.hule) {
201
+ board.hule(msg.hule);
202
+ let { l, shoupai, baojia, fubaopai, fu, fanshu,
203
+ damanguan, defen, hupai, fenpei } = msg.hule;
204
+ let hora_tehais = tehai(shoupai);
205
+ let hulepai = hora_tehais.pop();
206
+ if (baojia == null) hora_tehais.push(hulepai);
207
+ req = {
208
+ type: 'hora',
209
+ actor: board.player_id[l],
210
+ target: board.player_id[baojia == null ? l : baojia],
211
+ pai: hulepai,
212
+ uradora_markers: (fubaopai || []).map(p => hai(...p)),
213
+ hora_tehais: hora_tehais,
214
+ yakus: hupai.map(h => [ h.name,
215
+ `${h.fanshu}`[0] == '*'
216
+ ? 13 : h.fanshu ]),
217
+ fu: damanguan ? 20 : fu,
218
+ fan: damanguan ? 13 : fanshu,
219
+ hora_points: defen,
220
+ deltas: [],
221
+ scores: []
222
+ };
223
+ for (let l = 0; l < 4; l++) {
224
+ let id = board.player_id[l];
225
+ req.deltas[id] = fenpei[l];
226
+ req.scores[id] = board.defen[id] + fenpei[l];
227
+ }
228
+ }
229
+ else if (msg.pingju) {
230
+ board.pingju(msg.pingju);
231
+ let { name, shoupai, fenpei } = msg.pingju;
232
+ req = {
233
+ type: 'ryukyoku',
234
+ reason: name,
235
+ tehais: [],
236
+ tenpais: [],
237
+ deltas: [],
238
+ scores: []
239
+ };
240
+ for (let l = 0; l < 4; l++) {
241
+ let id = board.player_id[l];
242
+ let n_fulou = board.shoupai[l]._fulou.length;
243
+ req.tehais[id] = shoupai[l] ? tehai(shoupai[l])
244
+ : Array(13 - n_fulou * 3).fill('?');
245
+ req.tenpais[id] = name == '荒牌平局' && shoupai[l] != '';
246
+ req.deltas[id] = fenpei[l];
247
+ req.scores[id] = board.defen[id] + fenpei[l];
248
+ }
249
+ }
250
+ else if (msg.jieju) {
251
+ let { defen } = msg.jieju;
252
+ req = {
253
+ type: 'end_game',
254
+ scores: defen
255
+ };
256
+ this.send(req);
257
+ if (callback) callback({});
258
+ return;
259
+ }
260
+
261
+ this.send(req);
262
+
263
+ let res = await this.recv();
264
+
265
+ let reply;
266
+ if (res.type == 'reach') {
267
+ this._lizhi = this._id;
268
+ this.send(res);
269
+ res = await this.recv();
270
+ }
271
+ if (res.type == 'dahai') {
272
+ reply = { dapai: pai(res.pai) + (res.tsumogiri ? '_' : '')
273
+ + (this._lizhi != null ? '*' : '')};
274
+ }
275
+ else if (res.type == 'chi' || res.type == 'pon' ||
276
+ res.type == 'daiminkan')
277
+ {
278
+ let m = mianzi(res.actor, res.target, ...res.consumed, res.pai);
279
+ if (res.type == 'pon') this._peng.push(m);
280
+ reply = { fulou: m };
281
+ }
282
+ else if (res.type == 'ankan') {
283
+ reply = { gang: mianzi(res.actor, res.actor, ...res.consumed) }
284
+ }
285
+ else if (res.type == 'kakan') {
286
+ let i = this._peng.map(m => m.slice(0,2).replace(/0/,'5'))
287
+ .indexOf(pai(res.pai).replace(/0/,'5'));
288
+ reply = { gang: this._peng[i] + pai(res.pai)[1] };
289
+ }
290
+ else if (res.type == 'hora') {
291
+ reply = { hule: '-' };
292
+ }
293
+ else if (res.type == 'ryukyoku') {
294
+ reply = { daopai: '-' };
295
+ }
296
+ else if (res.type == 'none') {
297
+ reply = {};
298
+ }
299
+
300
+ if (callback) callback(reply);
301
+
302
+ if (msg.hule || msg.pingju) {
303
+ this.send({ type: 'end_kyoku' });
304
+ await this.recv();
305
+ }
306
+ };
307
+ }
package/lib/shan.js ADDED
@@ -0,0 +1,56 @@
1
+ /*
2
+ * デュプリケート対戦用牌山操作
3
+ */
4
+ "use strict";
5
+
6
+ const Majiang = require('@kobalab/majiang-core');
7
+
8
+ module.exports = class Shan {
9
+
10
+ constructor(pai, rule = Majiang.rule()) {
11
+ this._rule = rule;
12
+ this._wangpai = pai.splice(0, 14);
13
+ this._qipai = pai.splice(0, 13 * 4);
14
+ this._zimo = [[],[],[],[]];
15
+ let l = 0;
16
+ while (pai.length) {
17
+ this._zimo[l].push(pai.shift());
18
+ l = (l + 1) % 4;
19
+ }
20
+ this._lunban = -1;
21
+ this._paishu = 136 - 14;
22
+
23
+ this._baopai = [];
24
+ this._fubaopai = [];
25
+ this.kaigang();
26
+ }
27
+ static zhenbaopai(p) { return Majiang.Shan.zhenbaopai(p) }
28
+ lunban(l) { this._lunban = (l + 1) % 4 }
29
+ zimo() {
30
+ this._paishu--;
31
+ if (this._lunban < 0) return this._qipai.shift();
32
+ if (this._zimo[this._lunban].length)
33
+ return this._zimo[this._lunban].shift();
34
+ let l = [0,1,2,3].reduce((x,y)=>
35
+ this._zimo[x].length > this._zimo[y].length ? x : y);
36
+ return this._zimo[l].pop();
37
+ }
38
+ gangzimo() { return this.zimo() }
39
+ kaigang() {
40
+ this._baopai.push(this._wangpai.shift());
41
+ this._fubaopai.push(this._wangpai.shift());
42
+ return this;
43
+ }
44
+ close() { return this }
45
+ get paishu() { return this._paishu }
46
+ get baopai() {
47
+ if (this._rule['カンドラあり']) return this._baopai.concat();
48
+ else return [ this._baopai[0] ];
49
+ }
50
+ get fubaopai() {
51
+ if (! this._rule['裏ドラあり']) return;
52
+ if (this._rule['カンドラあり'] && this._rule['カン裏あり'])
53
+ return this._fubaopai.concat();
54
+ else return [ this._fubaopai[0] ]
55
+ }
56
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@kobalab/mjai-test-server",
3
+ "version": "0.1.1",
4
+ "description": "Mjaiボット対戦サーバー",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "bin": {
9
+ "mjai-test-server": "bin/server.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node bin/server.js"
13
+ },
14
+ "keywords": [
15
+ "麻雀",
16
+ "Mjai",
17
+ "電脳麻将"
18
+ ],
19
+ "author": "Satoshi Kobayashi",
20
+ "license": "MIT",
21
+ "type": "commonjs",
22
+ "dependencies": {
23
+ "@kobalab/majiang-core": "^1.4.1",
24
+ "yargs": "^17.7.3"
25
+ }
26
+ }