bot接続ガイド

自作のbotをこのサイトに接続し、ランダムマッチで人間や他のbotと対戦させるためのガイドです。

仕組みの概要

外部botはブラウザのクライアントと同じ Socket.IO 接続でプレイするプログラムです。専用のbotプロトコルはありません。 サーバーはどの接続に対しても「そのプレイヤーから見える情報」(自分の駒・持ち駒・時計・反則回数など)しか送りません。 botだからといって相手の駒が見えるような余分な情報は一切得られず、ついたて将棋の情報秘匿はサーバー側で構造的に保証されています。 対局は匿名です。相手のユーザー名・レート・bot判定は対局中は一切通知されず、終局後のgame:endで初めて明かされます。

1. botアカウントとAPIトークン

  1. 人間のアカウントでログインし、マイページ → bot管理でbotを作成します(1人3体まで)。
  2. 作成時にAPIトークン(tsb_ で始まる文字列)が一度だけ表示されます。安全に保管してください。
  3. トークンを漏らした場合はマイページから再発行(旧トークンは即失効)または失効ができます。

botはパスワードでのログインができない専用アカウントで、人間と同じレーティングを持ちます。

2. 接続

Socket.IO クライアントで、handshake の auth.token にAPIトークンを渡します。接続先はこのサイト(https://beta.tsuitate.info)です。

import { io } from 'socket.io-client';

const socket = io('https://beta.tsuitate.info', {
  auth: { token: 'tsb_...' }, // マイページで発行したAPIトークン
  transports: ['websocket']
});

socket.on('connect', () => console.log('connected'));
socket.on('connect_error', (e) => console.error(e.message)); // 'unauthorized' など

トークンが不正・失効済みの場合は connect_error(message: unauthorized)になります。

3. イベント契約

すべての送受信は下記のイベントで行います。ペイロードの型は次の通りです。

クライアント → サーバー

イベントペイロード / ack内容
queue:joinack: { ok, error? }ランダムマッチの待機列に入る
queue:leaveack: { ok }待機列から抜ける
game:move{ gameId, usi } / ack: MoveAck着手
game:resign{ gameId }投了
game:syncack: { state: PlayerView | null }現在の局面を全量取得。再接続時や状態の取り直しに使う

サーバー → クライアント

イベント内容
match:foundマッチ成立。{ gameId, yourColor }(匿名対局のため相手の身元は含まない)
game:statePlayerView。対局開始時や自分の手番で届く
game:moveAccepted自分の着手の確定。{ moveNumber, clocks, captured? }(取った駒があれば captured
game:opponentMoved相手が指した(内容は不明)。{ moveNumber, clocks, capturedYourPieceAt? }(自駒が取られたマス)
game:foul自分の反則。{ foulCount, clocks }(理由は通知されない。clocks は精算後の時計で、反則した手にも1手3秒の加算が付く)
game:opponentFoul相手の反則宣言。{ opponentFoulCount, clocks }
game:check王手宣言(両者に通知)。{ inCheck: Color }
clock:update時計の精算結果(ClockState
game:opponentDisconnected / game:opponentReconnected相手の接続状態
game:end終局。全公開の棋譜・終局図・レート変動。opponent(相手の身元)もここで初めて公開される

型定義

ブラウザクライアントとbotで共有している型です。そのままコピーして使えます。

type Color = 'sente' | 'gote';

// USI表記。マス "7g"(筋1-9 + 段a-i)、指し手 "7g7f" / "8h2b+" / "P*5e"
type UsiSquare = string;
type UsiMove = string;

type PieceRole =
  | 'pawn' | 'lance' | 'knight' | 'silver' | 'gold' | 'bishop' | 'rook' | 'king'
  | 'tokin' | 'promotedlance' | 'promotedknight' | 'promotedsilver' | 'horse' | 'dragon';
type HandRole = 'pawn' | 'lance' | 'knight' | 'silver' | 'gold' | 'bishop' | 'rook';
type Hand = Partial<Record<HandRole, number>>;

interface VisiblePiece { square: UsiSquare; role: PieceRole; }

interface ClockState {
  senteMs: number;
  goteMs: number;
  running: Color | null;   // 進行中の手番(終局後は null)
  serverTime: number;      // サーバー時刻(epoch ms)。表示補間用
}

interface OpponentInfo {
  username: string;
  rating: number;
  isBot: boolean;          // 相手も bot なら true
}
// 匿名対局のため、対局中は相手の身元(OpponentInfo)は一切通知されない。
// 終局時の game:end でのみ公開される。

// 対局中に自分へ送られてくる「自分から見える情報」の全量
interface PlayerView {
  gameId: string;
  yourColor: Color;
  yourPieces: VisiblePiece[]; // 自分の駒だけ。相手の駒は一切含まれない
  yourHand: Hand;
  turn: Color;
  moveNumber: number;         // 次に指されるのが何手目か(1始まり)
  clocks: ClockState;
  fouls: { you: number; opponent: number };
  youInCheck: boolean;
  opponentInCheck: boolean;
  status: 'playing' | 'ended';
}

game:move の ack(MoveAck):

type MoveAck =
  | { ok: true }
  | { ok: false; reason: 'foul'; foulCount: number }  // 反則。手番は変わらない
  | { ok: false; reason: 'error'; error: string };    // 手番違い・不正ペイロード等

socket.emit('game:move', { gameId, usi: '7g7f' }, (ack: MoveAck) => {
  if (!ack.ok && ack.reason === 'foul') {
    // 別の手を試す。同じ局面で同じ反則手を繰り返さないこと
  }
});

4. 指し手の表記(USI)

指し手は USI 表記の文字列です。移動は 7g7f(成りは末尾に +、例 8h2b+)、 打ちは P*5e。マスは筋(1-9)+段(a-i、a が最上段)で表します。 盤上の見えない駒に関する合法性はサーバーだけが判定します。

USI文字列を組み立てる最小ヘルパー(依存なし):

const RANKS = 'abcdefghi';

// マス文字列 → 座標。file:筋(1-9), rank:段(1-9, a=1)
const parseSquare = (sq) => ({ file: +sq[0], rank: RANKS.indexOf(sq[1]) + 1 });
const makeSquare = (c) => `${c.file}${RANKS[c.rank - 1]}`;

// 移動(成りなら promote=true で末尾に "+")
const makeMove = (from, to, promote) =>
  `${makeSquare(from)}${makeSquare(to)}${promote ? '+' : ''}`;

// 打ち。role は 'pawn'|'lance'|'knight'|'silver'|'gold'|'bishop'|'rook'
const DROP = { pawn:'P', lance:'L', knight:'N', silver:'S', gold:'G', bishop:'B', rook:'R' };
const makeDrop = (role, to) => `${DROP[role]}*${makeSquare(to)}`;

5. ルール上の注意(botを書く人向け)

  • 反則しても手番は変わりません。 ack が reason: 'foul' なら別の手を試してください。同じ局面で同じ反則手を繰り返さないこと(累計10回で反則負け)。時計は反則の時点で精算され、反則した手にも1手3秒の加算が付きます(game:foulclocks)。
  • 相手の駒は見えないため、合法だと思って指した手が反則になるのは正常な流れです。反則になった手を除外して指し直します。
  • 時計はフィッシャー 300秒 + 3秒思考が遅いと普通に時間切れで負けます。
  • 切断は60秒でみなし負け。再接続したら game:sync で状態を取り直してください。
  • 対局は同時に1局のみ。対局中の queue:join は拒否されます。

6. マッチングポリシー

  • botはランダムマッチの待機列に人間と同じように並びます。
  • ランダムマッチの受付時間はbotには適用されません。常時キューに参加でき、受付時間外はbot同士でマッチします。
  • bot同士もマッチします(同一所有者のbot同士を除く)。
  • 所有者と自分のbot・同一所有者のbot同士はマッチしません(レート操作防止)。
  • 「botと対戦したくない」設定(マイページ)のユーザーとはマッチしません。
  • 人間の相手候補としては人間が優先され、レート許容幅内に人間がいないときだけbotとマッチします。
  • botの開始レートは1200です(人間は1500)。

7. 最小サンプル

接続からマッチ・着手・再キューまでの骨組みです。generateCandidates(自駒だけを見た候補手の生成)と pickBest(手の選択)はbotの戦略として自分で実装してください。 Node.js で npm i socket.io-client 後、環境変数 TSUITATE_BOT_TOKEN にトークンを設定して実行します。

import { io } from 'socket.io-client';

const url = process.env.TSUITATE_URL ?? 'https://beta.tsuitate.info';
const token = process.env.TSUITATE_BOT_TOKEN;
const socket = io(url, { auth: { token }, transports: ['websocket'] });

let gameId = null;
const foulTried = new Set(); // この手番中に反則になった手

function think() {
  if (!gameId) return;
  socket.emit('game:sync', { gameId }, ({ state: view }) => {
    if (!view || view.status !== 'playing' || view.turn !== view.yourColor) return;

    // 自駒だけを見て合法手候補を作る(相手の駒は見えないので推測で埋める)
    const candidates = generateCandidates(view).filter((usi) => !foulTried.has(usi));
    if (candidates.length === 0) {
      socket.emit('game:resign', { gameId: view.gameId }, () => {});
      return;
    }
    const usi = pickBest(candidates);
    socket.emit('game:move', { gameId: view.gameId, usi }, (ack) => {
      if (!ack.ok && ack.reason === 'foul') {
        foulTried.add(usi);
        setTimeout(think, 300); // 別の手で指し直す
      }
    });
  });
}

socket.on('connect', () => socket.emit('queue:join', () => {}));
socket.on('match:found', (p) => { gameId = p.gameId; foulTried.clear(); });
socket.on('game:state', () => setTimeout(think, 800));      // 対局開始 / 自分の手番
socket.on('game:opponentMoved', () => setTimeout(think, 800)); // 相手が指した
socket.on('game:end', (p) => {
  gameId = null;
  setTimeout(() => socket.emit('queue:join', () => {}), 3000); // 自動で再度並ぶ
});

不明点や不具合の報告は運営までお問い合わせください。