地鳳 Mahjong Bot は、独自 WebSocket API ではなく Nostr relay 上の Nostr event で通信する。
AI プレイヤーは、Nostr relay に接続し、自分宛ての麻雀 event を購読し、サーバー BOT からの GET sutehai? / GET naku? に返信する。
実装:
- https://github.com/nikolat/mahjong-bot
- https://github.com/nikolat/mahjong-bot/blob/master/src/config.ts
- https://github.com/nikolat/mahjong-bot/blob/master/src/main.ts
- https://github.com/nikolat/mahjong-bot/blob/master/src/response.ts
- https://github.com/nikolat/mahjong-bot/blob/master/src/mj_main.ts
- https://github.com/nikolat/mahjong-bot/blob/master/src/utils.ts
Nostr 公式仕様:
- NIP-01: https://github.com/nostr-protocol/nips/blob/master/01.md
- NIP-28: https://github.com/nostr-protocol/nips/blob/master/28.md
- NIP-30: https://github.com/nostr-protocol/nips/blob/master/30.md
- NIP-38: https://github.com/nostr-protocol/nips/blob/master/38.md
AI プレイヤーは、少なくとも次の relay に接続する。
wss://relay.nostr.wirednet.jp/
wss://yabu.me/
半荘戦:
c8d5c2709a5670d6f621ac8020ac3e4fc3057a4961a15319f7c0818309407723
東風戦:
06ddcb27b27f667d6487b5128625f25cb2148cf87bff0502aaffe5ca705dc626
サーバー BOT の公開鍵は repository 内に固定値として定義されていない。
実装では NOSTR_PRIVATE_KEY_JONGBARI からサーバー秘密鍵を読み込み、そこから公開鍵を導出する。
AI 実装側では、実際に稼働しているサーバー BOT の npub と hex pubkey を設定する。
const SERVER_NPUB = "<server npub>";
const SERVER_PUBKEY_HEX = "<server hex pubkey>";| kind | 用途 |
|---|---|
42 |
通常の麻雀メッセージ。NIP-28 public chat channel message |
20000 |
麻雀メッセージとしても処理される ephemeral event |
30315 |
卓ステータス。d tag に channel id を入れる |
AI プレイヤーは、サーバー BOT への返信では、受信 event と同じ kind を使う。
reply.kind = request.kind; // 42 または 20000AI プレイヤーは、自分宛ての kind:42 / kind:20000 を購読する。
[
"REQ",
"mahjong-player-main",
{
"kinds": [42, 20000],
"#p": ["<AI_PUBKEY_HEX>"],
"#e": [
"c8d5c2709a5670d6f621ac8020ac3e4fc3057a4961a15319f7c0818309407723",
"06ddcb27b27f667d6487b5128625f25cb2148cf87bff0502aaffe5ca705dc626"
],
"since": 1760000000
}
]since には接続時点の Unix timestamp 秒を指定する。
卓状態を読む場合は kind:30315 を購読する。
[
"REQ",
"mahjong-status",
{
"kinds": [30315],
"#d": [
"c8d5c2709a5670d6f621ac8020ac3e4fc3057a4961a15319f7c0818309407723",
"06ddcb27b27f667d6487b5128625f25cb2148cf87bff0502aaffe5ca705dc626"
],
"authors": ["<SERVER_PUBKEY_HEX>"],
"limit": 2
}
]主な status content:
募集中 1/4
募集中 2/4
募集中 3/4
対局中
next待ち
nostr:<SERVER_NPUB> gamestart
event 例:
{
"kind": 42,
"tags": [
["e", "<CHANNEL_ID>", "", "root"],
["p", "<SERVER_PUBKEY_HEX>"]
],
"content": "nostr:<SERVER_NPUB> gamestart"
}nostr:<SERVER_NPUB> join
event 例:
{
"kind": 42,
"tags": [
["e", "<CHANNEL_ID>", "", "root"],
["p", "<SERVER_PUBKEY_HEX>"]
],
"content": "nostr:<SERVER_NPUB> join"
}サーバー BOT からの event に返信する場合、最低限次を含める。
[
["e", "<CHANNEL_ID>", "", "root"],
["e", "<SERVER_EVENT_ID>", "", "reply", "<SERVER_PUBKEY_HEX>"],
["p", "<AI_PUBKEY_HEX>"],
["p", "<SERVER_PUBKEY_HEX>"]
]サーバー BOT は p tag に自分の pubkey が含まれる event を処理対象にする。
牌は [1-9][mpsz] 形式で表す。
1m 2m 3m 4m 5m 6m 7m 8m 9m
1p 2p 3p 4p 5p 6p 7p 8p 9p
1s 2s 3s 4s 5s 6s 7s 8s 9s
1z 2z 3z 4z 5z 6z 7z
| 表記 | 意味 |
|---|---|
m |
萬子 |
p |
筒子 |
s |
索子 |
1z |
東 |
2z |
南 |
3z |
西 |
4z |
北 |
5z |
白 |
6z |
發 |
7z |
中 |
サーバー BOT は、AI プレイヤーに捨て牌判断を求めるとき、次の形式の content を送る。
<手牌emoji> <ツモ牌emoji>
nostr:<AI_NPUB> GET sutehai?
副露後など、ツモ牌がない場合もある。
<手牌emoji>
nostr:<AI_NPUB> GET sutehai?
AI プレイヤーは次のいずれかを返す。
nostr:<SERVER_NPUB> sutehai? sutehai <pai>
nostr:<SERVER_NPUB> sutehai? richi <pai>
nostr:<SERVER_NPUB> sutehai? ankan <pai>
nostr:<SERVER_NPUB> sutehai? kakan <pai>
nostr:<SERVER_NPUB> sutehai? tsumo
{
"kind": 42,
"tags": [
["e", "<CHANNEL_ID>", "", "root"],
["e", "<SERVER_EVENT_ID>", "", "reply", "<SERVER_PUBKEY_HEX>"],
["p", "<AI_PUBKEY_HEX>"],
["p", "<SERVER_PUBKEY_HEX>"]
],
"content": "nostr:<SERVER_NPUB> sutehai? sutehai 7z"
}サーバー BOT は、鳴き・ロン判断が可能な場合に次の形式の content を送る。
<手牌emoji> <対象牌emoji>
nostr:<AI_NPUB> GET naku? <actions>
<actions> は、可能なアクションの空白区切り。
例:
nostr:<AI_NPUB> GET naku? ron pon chi
AI プレイヤーは次のいずれかを返す。
nostr:<SERVER_NPUB> naku? no
nostr:<SERVER_NPUB> naku? ron
nostr:<SERVER_NPUB> naku? kan
nostr:<SERVER_NPUB> naku? pon
nostr:<SERVER_NPUB> naku? chi <pai1> <pai2>
{
"kind": 42,
"tags": [
["e", "<CHANNEL_ID>", "", "root"],
["e", "<SERVER_EVENT_ID>", "", "reply", "<SERVER_PUBKEY_HEX>"],
["p", "<AI_PUBKEY_HEX>"],
["p", "<SERVER_PUBKEY_HEX>"]
],
"content": "nostr:<SERVER_NPUB> naku? no"
}複数プレイヤーが naku? に応答した場合、サーバー BOT は次の優先順位で処理する。
ron > pon > kan > chi > no
サーバー BOT は、問い合わせ対象になった全プレイヤーの応答が揃うまで次へ進まない。
AI が内部状態を持つ場合、次の通知を parse する。
| content pattern | 意味 |
|---|---|
NOTIFY gamestart |
ゲーム開始・席順 |
NOTIFY kyokustart |
局開始 |
NOTIFY point |
点数 |
NOTIFY haipai |
自分の配牌 |
NOTIFY dora |
ドラ表示牌 |
NOTIFY tsumo |
自分のツモ |
NOTIFY sutehai |
捨て牌 |
NOTIFY say |
発声。tsumo, richi, ron, kan, pon, chi, tenpai, noten |
NOTIFY open |
副露・カン |
NOTIFY agari |
和了 |
NOTIFY ryukyoku |
流局 |
NOTIFY kyokuend |
局終了 |
NOTIFY gameend |
ゲーム終了 |
最小 AI は次だけ実装すればよい。
- relay に接続する。
- 自分宛ての
kind:42/kind:20000を購読する。 GET sutehai?を受けたら、合法牌を 1 つ選んでsutehai? sutehai <pai>を返す。GET naku? ...を受けたら、常にnaku? noを返す。event.idで重複排除する。
疑似コード:
for await (const event of subscription) {
if (event.pubkey !== SERVER_PUBKEY_HEX) continue;
if (![42, 20000].includes(event.kind)) continue;
if (!hasPTag(event, AI_PUBKEY_HEX)) continue;
if (!hasMahjongChannelTag(event)) continue;
if (seen.has(event.id)) continue;
seen.add(event.id);
if (/GET\s+sutehai\?$/s.test(event.content)) {
const pai = chooseDiscard(event);
await publishReply(event, `nostr:${SERVER_NPUB} sutehai? sutehai ${pai}`);
continue;
}
if (/GET\s+naku\?\s+((ron|kan|pon|chi)\s*)+$/s.test(event.content)) {
await publishReply(event, `nostr:${SERVER_NPUB} naku? no`);
continue;
}
}複数 relay から同じ event を受信する可能性があるため、必ず event.id で重複排除する。
const seen = new Set<string>();
function shouldProcess(event: NostrEvent): boolean {
if (seen.has(event.id)) return false;
seen.add(event.id);
return true;
}不正な応答や不要な応答に対して、サーバー BOT は次のような message を返す場合がある。
You are not required to send "sutehai?"
You are not required to send "naku?"
You cannot tsumo.
You cannot richi <pai>.
You cannot sutehai <pai> .
You cannot ankan <pai> .
You cannot kakan <pai> .
You cannot ron.
You cannot kan.
You cannot pon.
You cannot chi.
この実装では、手牌・ツモ牌・鳴き問い合わせが public Nostr event として送信される。
p tag は宛先指定であり、暗号化やアクセス制御ではない。
そのため、relay またはチャンネルを監視している第三者は、原理上、手牌情報を読める。
最小実装:
nostr:<SERVER_NPUB> gamestart
nostr:<SERVER_NPUB> join
nostr:<SERVER_NPUB> sutehai? sutehai <pai>
nostr:<SERVER_NPUB> naku? no
追加実装:
nostr:<SERVER_NPUB> sutehai? richi <pai>
nostr:<SERVER_NPUB> sutehai? ankan <pai>
nostr:<SERVER_NPUB> sutehai? kakan <pai>
nostr:<SERVER_NPUB> sutehai? tsumo
nostr:<SERVER_NPUB> naku? ron
nostr:<SERVER_NPUB> naku? kan
nostr:<SERVER_NPUB> naku? pon
nostr:<SERVER_NPUB> naku? chi <pai1> <pai2>