API 仕様書

AI クライアント開発用の REST API です。

リバーシ AI 対戦 API 仕様書 v1.6

KG motors AI リバーシクラブ — ベース URL: https://imperfect.jp

変更履歴(v1.6)

  • 予約時に 非法手ルールillegal_move_policy)を選択可能に
  • rulesillegal_move_policy / illegal_move_policy_label を追加(illegal_move_forfeit は後方互換)
  • POST /move の非法手挙動をルール別に明記(forfeit / reject
  • GET /history にリプレイ整合チェック・非法手ログフィールドを追加
  • ユーザー名

  • 3〜32 文字(UTF-8 の文字数)
  • 使用可: 日本語(ひらがな・カタカナ・漢字)、英数字、アンダースコア _
  • 使用不可: 空白、/ など URL 向け記号
  • プロフィール URL: /players/{ユーザー名}(日本語は URL エンコード)

  • 試合形式

  • マッチ = 複数局のセット
  • 2勝差先取: 一方が相手より 2局多く 勝った時点でマッチ終了(2-0, 3-1, 4-2 …)
  • 先手後手交互: 各局ごとに黒白が入れ替わる(第1局の先手は予約時の色指定)
  • 1局 = 通常のリバーシ1対局(石数 or 時間切れ等で決着)
  • 非法手ルール(予約時に指定、試合中は変更不可):
  • - forfeit(既定): 非法手 → その局即負け

    - reject: 非法手 → 拒否(400)して同じ手番のまま再送(局は続行)

    流れ

    1. 相手・開始日時・1手秒数・第1局の色・非法手ルールを指定して予約

    2. 開始前に双方が AI を起動し GET /api/v1/games/{id} をポーリング(ready)

    3. 開始時刻に双方 ready → 第1局開始

    4. 1局終了 → スコア更新 → 2勝差未達なら 先手後手を入れ替えて 次局を自動開始

    5. 2勝差達成でマッチ終了


    POST /api/v1/games

    {
      "opponent": "player2",
      "scheduled_at": "2026-05-20 15:00:00",
      "move_timeout_sec": 10,
      "color": "black",
      "illegal_move_policy": "forfeit"
    }
    フィールド必須説明
    opponentはい相手ユーザー名(GET /api/v1/users/opponents で一覧取得可)
    scheduled_atはい開始日時
    move_timeout_sec1手秒数(3〜120)
    color第1局のあなたの色: black / white / random
    illegal_move_policy非法手の扱い(下表)。省略時 reject
    illegal_move_policy説明
    reject標準。非法手は盤面に反映されず HTTP 400。同じ手番・同じ turn_deadlineで合法手を再送
    forfeit即負け(大会ルール)。非法手を送るとその局が終了し、着手者の負け(HTTP 200)
  • 相手は了承前に GET /rules または試合予定表で非法手ルールを確認できる
  • 予約時に forfeit を明示した試合のみ即負け

  • GET /api/v1/users/opponents

    認証必須 — 対戦相手候補(自分・テストボット除く)

    {
      "opponents": [
        { "id": 2, "username": "player2" }
      ]
    }

    GET /api/v1/games/{id}/rules

    試合開始前にルールを取得(ready にはならない — 設定確認専用)

    {
      "match": {
        "match_id": "...",
        "status": "scheduled",
        "scheduled_at": "2026-05-20 15:00:00",
        "seconds_until_start": 300,
        "your_side": "a",
        "your_first_round_color": "black",
        "players": {
          "a": { "username": "you" },
          "b": { "username": "opp" }
        },
        "rules": {
          "format": "lead_by_two",
          "move_timeout_sec": 10,
          "wins_to_lead": 2,
          "alternating_colors": true,
          "illegal_move_policy": "forfeit",
          "illegal_move_policy_label": "非法手は即負け",
          "illegal_move_forfeit": true,
          "timeout_forfeit": true,
          "no_show_forfeit": true,
          "your_first_round_color": "black",
          "description": "2勝差先取(2局先取)。各局の先手後手は交互。非法手でその局負け。..."
        }
      }
    }

    GET /api/v1/games

    認証必須 — 自分が参加する試合の一覧(最大50件、開始日時の降順)

    {
      "games": [
        {
          "id": "337227b95f4e1b3686878ce36217db4f",
          "status": "scheduled",
          "awaiting_acceptance": false,
          "scheduled_at": "2026-05-20 15:00:00",
          "seconds_until_start": 300,
          "your_side": "a",
          "your_color": "black",
          "your_turn": false,
          "your_ready": true,
          "your_can_accept": false,
          "your_can_withdraw": true,
          "players": {
            "a": { "user_id": 1, "username": "you", "ready": true },
            "b": { "user_id": 2, "username": "opp", "ready": false }
          },
          "match_score": { "a": 0, "b": 0, "wins_to_lead": 2 },
          "rules": {
            "move_timeout_sec": 10,
            "wins_to_lead": 2,
            "illegal_move_policy": "reject",
            "illegal_move_policy_label": "非法手は拒否(再送)",
            "illegal_move_forfeit": false
          }
        }
      ]
    }
  • 各要素のフィールドは GET /api/v1/games/{id}game と同型(一覧用に盤面等を含む)
  • AI クライアントは statuspending / scheduled / active の試合を検出して接続できる
  • status説明
    pending了承待ち(相手 B の承認前)
    scheduled了承済み・開始前(双方 ready 待ち)
    active対局中
    finished終了

    GET /api/v1/games/{id}

    {
      "game": {
        "id": "...",
        "status": "active",
        "awaiting_acceptance": false,
        "accepted_at": "2026-05-20 14:55:00",
        "your_can_accept": false,
        "your_can_withdraw": false,
        "scheduled_at": "2026-05-20 15:00:00",
        "seconds_until_start": null,
        "match_score": { "a": 1, "b": 0, "wins_to_lead": 2 },
        "players": {
          "a": { "user_id": 1, "username": "you", "ready": true },
          "b": { "user_id": 2, "username": "opp", "ready": true }
        },
        "round": { "number": 2, "status": "active" },
        "black": { "user_id": 2, "username": "opp", "discs": 12 },
        "white": { "user_id": 1, "username": "you", "discs": 10 },
        "turn": "white",
        "board": [[0,0,0,0,0,0,0,0], "... 8x8 ..."],
        "legal_moves": ["c4", "d3"],
        "your_color": "white",
        "your_turn": true,
        "your_side": "a",
        "your_ready": true,
        "turn_deadline": "2026-05-20 15:00:42",
        "rules": {
          "format": "lead_by_two",
          "move_timeout_sec": 10,
          "ready_ttl_sec": 30,
          "wins_to_lead": 2,
          "alternating_colors": true,
          "illegal_move_policy": "forfeit",
          "illegal_move_policy_label": "非法手は即負け",
          "illegal_move_forfeit": true,
          "timeout_forfeit": true,
          "no_show_forfeit": true,
          "disconnect_forfeit": true,
          "acceptance_required": true,
          "your_first_round_color": "black",
          "description": "2勝差先取(2局先取)。各局の先手後手は交互。1手10秒以内。非法手でその局負け。..."
        },
        "winner": null,
        "end_reason": null,
        "created_at": "2026-05-20 14:50:00",
        "updated_at": "2026-05-20 15:00:12"
      }
    }

    フィールド補足

  • players.a = 予約作成者、players.b = 相手
  • black / white / board / turn / legal_moves現在の局status=active 時)。それ以外は初期盤または空
  • your_side: "a" または "b"
  • your_color: 現在の局での自分の色 "black" / "white"(開始前は第1局の予定色)
  • your_turn: 自分の着手番か(status=active かつ手番一致時のみ true
  • your_ready: 自分が ready 状態か(GET ポーリングで更新)
  • turn_deadline: 現在の手の締切日時。形式 YYYY-MM-DD HH:MM:SS(サーバー時刻)。status=active かつ局進行中のみ。超過後の POST /move は拒否され時間切れ負けになる
  • seconds_until_start: pending / scheduled 時のみ。開始までの秒数(0 以上)
  • your_can_accept: 相手側(B)が了承できるか(pending 時)
  • your_can_withdraw: 予約者が取り下げできるか、または scheduled で参加取消できるか
  • rules.move_timeout_sec: 1手あたりの制限秒数(3〜120)。AI は思考上限の目安に使う
  • rules.illegal_move_policy: "forfeit" または "reject"(非法手の扱い)
  • rules.illegal_move_policy_label: 表示用ラベル(例: "非法手は即負け"
  • rules.illegal_move_forfeit: 後方互換illegal_move_policy === "forfeit" のとき true
  • board: 8×8 行列。1=黒、-1=白、0=空
  • AI クライアント向け: 思考時間の目安

    1手の探索上限(秒)は次の 小さい方 を推奨:

    1. rules.move_timeout_sec マイナス POST 用マージン(例: 0.5秒)

    2. turn_deadline までの残り秒数マイナスマージン

    import time
    from datetime import datetime
    
    def think_budget_sec(game: dict, margin: float = 0.5) -> float:
        rule = (game.get("rules") or {}).get("move_timeout_sec", 10)
        budget = float(rule) - margin
        dl = game.get("turn_deadline")
        if dl:
            remaining = datetime.strptime(dl, "%Y-%m-%d %H:%M:%S").timestamp() - time.time()
            budget = min(budget, remaining - margin)
        return max(0.1, budget)
    end_reason説明
    match_won2勝差達成
    no_show開始時 AI 未起動
    not_accepted了承期限切れ
    normal / timeout / illegal_move / disconnect最終局の終了理由(マッチ続行中は次局へ)

    GET /api/v1/games/{id}/live

    認証不要 — ブラウザ観戦・配信用 JSON(/live/{id} ページがポーリング)

    クエリ: viewer(省略可)— 32 文字 hex の観覧者 ID。指定するとハートビートとして記録され、viewer_count に反映される。

    {
      "live": {
        "id": "...",
        "status": "active",
        "viewer_count": 12,
        "match_score": { "a": 1, "b": 0, "wins_to_lead": 2 },
        "players": {
          "a": { "username": "you", "ready": true },
          "b": { "username": "opp", "ready": true }
        },
        "connections": {
          "a": {
            "side": "a",
            "username": "you",
            "connected": true,
            "status": "online",
            "status_label": "接続中",
            "last_seen_at": "2026-05-20 15:00:12",
            "seconds_since_seen": 2,
            "ready_ttl_sec": 30
          },
          "b": { "...": "..." }
        },
        "ai_setup": {
          "api_base": "https://imperfect.jp",
          "match_id": "...",
          "live_url": "https://imperfect.jp/live/...",
          "endpoints": {
            "poll": "https://imperfect.jp/api/v1/games/...",
            "rules": "https://imperfect.jp/api/v1/games/.../rules",
            "move": "https://imperfect.jp/api/v1/games/.../move",
            "live_json": "https://imperfect.jp/api/v1/games/.../live"
          },
          "auth_header": "Authorization: Bearer <API_KEY>",
          "poll_interval_sec": 0.5,
          "sample_command": "OTHELLO_API_KEY=<key> python3 sample_ai.py ..."
        },
        "board": [[...]],
        "black": { "username": "opp", "discs": 12 },
        "white": { "username": "you", "discs": 10 },
        "turn": "black",
        "recent_moves": [
          { "move_notation": "c4", "username": "you", "created_at": "..." }
        ],
        "rules": {
          "move_timeout_sec": 10,
          "wins_to_lead": 2,
          "illegal_move_policy": "forfeit",
          "illegal_move_policy_label": "非法手は即負け",
          "illegal_move_forfeit": true
        }
      }
    }
  • connections: 各 AI の API ポーリング状態(ready_ttl_sec 以内 = 接続中)
  • viewer_count: 直近 live_viewer_ttl_sec(既定 10 秒)以内に API をポーリングした観覧者数
  • ai_setup: 観戦者向けの接続情報(API キーは含まない
  • legal_moves / your_turn は含まない(観戦専用)
  • Web UI: https://imperfect.jp/live/{match_id}


    GET /api/v1/ranking

    認証不要 — マッチ勝敗ランキング

    クエリ: limit(省略時 50、最大 100)

    {
      "ranking": [
        {
          "rank": 1,
          "user_id": 1,
          "username": "player1",
          "wins": 5,
          "losses": 2,
          "draws": 0,
          "matches_played": 7,
          "win_rate": 71.4
        }
      ]
    }

    Web UI: /ranking


    GET /api/v1/schedule

    認証不要 — 試合予定表(テスト対局除く)

    {
      "schedule": {
        "live": [
          {
            "id": "...",
            "status": "active",
            "scheduled_at": "...",
            "move_timeout_sec": 10,
            "wins_to_lead": 2,
            "illegal_move_policy": "reject",
            "illegal_move_policy_label": "非法手は拒否(再送)",
            "players": { "a": { "username": "...", "ready": true }, "b": { "..." } },
            "match_score": { "a": 0, "b": 0 }
          }
        ],
        "scheduled": [ "..." ],
        "recent": [ "..." ]
      }
    }

    クエリ: recent(最近の結果件数、省略時 20)

    Web UI: /schedule


    GET /api/v1/players/{username}

    認証不要 — プレイヤー戦績

    {
      "player": {
        "user_id": 1,
        "username": "player1",
        "matches": { "wins": 5, "losses": 2, "draws": 0, "played": 7, "win_rate": 71.4 },
        "rounds": { "won": 12, "lost": 8, "drawn": 1, "played": 21 },
        "recent_matches": [ "..." ]
      }
    }

    Web UI: /players/{username}


    API 接続テスト(サンドボックス)

    自作 AI が API 仕様どおり動くか確認する機能。テスト対局は ランキング・戦績に含まれません

    GET /api/v1/test/ping

    認証必須 — API キーが有効か確認

    { "ping": { "ok": true, "username": "you", "server_time": "..." } }

    POST /api/v1/test/start

    認証必須 — テストボットとのサンドボックス対局を即時作成

    {
      "test": {
        "id": "...",
        "match_id": "...",
        "status": "running",
        "checks": [ { "id": "ping", "label": "...", "done": false } ],
        "ai_setup": { "sample_command": "OTHELLO_API_KEY=<key> python3 test_ai.py ..." }
      }
    }

    GET /api/v1/test/runs/{id}

    認証必須 — チェックリスト進捗

    GET /api/v1/test/runs/latest

    認証必須 — 直近のテスト結果

    合格条件(すべて達成):

    1. GET /api/v1/test/ping

    2. GET /api/v1/games

    3. GET /api/v1/games/{id}/rules

    4. GET /api/v1/games/{id} を2回以上(ポーリング)

    5. POST /api/v1/games/{id}/move

    6. テスト対局が finished になるまでポーリング

    Web UI: /test — 自動テストスクリプト: web/client/test_ai.py


    GET /api/v1/games/{id}/history

    認証不要 — 試合の全記録(各局の着手一覧・タイムライン・リプレイ)

    {
      "history": {
        "id": "...",
        "scheduled_at": "...",
        "move_timeout_sec": 10,
        "illegal_move_policy": "forfeit",
        "illegal_move_policy_label": "非法手は即負け",
        "players": { "a": { "username": "..." }, "b": { "username": "..." } },
        "match_score": { "a": 2, "b": 1 },
        "rounds": [
          {
            "round_number": 1,
            "black": { "username": "...", "discs": 33 },
            "white": { "username": "...", "discs": 31 },
            "moves": [
              { "move_number": 1, "notation": "c4", "username": "...", "color": "black", "created_at": "..." }
            ],
            "replay_frames": [
              {
                "step": 0,
                "label": "開始",
                "board": [[...]],
                "black_discs": 2,
                "white_discs": 2,
                "turn": "black"
              },
              {
                "step": 1,
                "label": "c4",
                "notation": "c4",
                "move_number": 1,
                "username": "...",
                "color": "black",
                "board": [[...]],
                "black_discs": 4,
                "white_discs": 1,
                "turn": "white"
              }
            ],
            "opening_replay_frames": [ "..." ],
            "replay_consistent": true,
            "replay_diverged_at": null,
            "illegal_move": {
              "notation": "a1",
              "move_index": 0,
              "username": "player2",
              "error_message": "非法手: a1",
              "created_at": "2026-05-20 15:12:00"
            },
            "final_board": [[...]],
            "end_reason": "illegal_move",
            "end_reason_label": "非法手"
          }
        ],
        "timeline": [
          { "type": "match_scheduled", "at": "...", "label": "試合予約", "detail": "..." },
          { "type": "move", "at": "...", "round": 1, "label": "c4", "detail": "..." },
          { "type": "match_end", "at": "...", "label": "マッチ終了", "detail": "..." }
        ]
      }
    }

    フィールド補足(history)

    フィールド説明
    replay_frames棋譜から再生した各手の盤面。先頭 step: 0 は開始局面
    opening_replay_frames序盤のみ(設定 opening_replay_max_moves 手まで)
    replay_consistent棋譜再生結果が DB 最終盤面と一致するか
    replay_diverged_at不一致が始まった手数(一致時は null
    illegal_moveその局の非法手ログ(最新1件)。非法手がなければ null
    replay_frames[].authoritativetrue のとき DB 由来の終局フレーム(棋譜とズレた場合に末尾に付く)
  • 着手は DB に永続保存される。非法手そのものは棋譜(moves)に含まれない
  • illegal_moveillegal_move_logs テーブルから取得(v1.6 以降の試合)
  • 旧データで棋譜と DB 盤面が食い違う場合、replay_consistent: false となり末尾に正しい終局盤面が付く
  • Web UI: /matches/{match_id}


    GET /api/v1/games/viewers

    認証不要 — 複数試合の同時観覧者数(ライブ配信用)

    クエリ: ids — カンマ区切りの試合 ID(最大 50 件想定)

    {
      "viewers": {
        "abc123...": 5,
        "def456...": 12
      }
    }
  • 直近 live_viewer_ttl_sec 以内に /live API をポーリングした観覧者数
  • 試合予定表・ダッシュボードが 5 秒ごとに更新

  • POST /api/v1/games/{id}/move

    現在の局に着手(status=active かつ your_turn=true のとき)

    { "move": "c4" }
    説明
    "c4"着手(a1h8、小文字可)
    "pass"パス(合法手がないときのみ)

    成功時

  • HTTP 200。レスポンス body の game は更新後の試合状態
  • エラー時

    条件HTTP挙動
    締切超過400その局は 時間切れ負けtimeout
    手番でない / 参加者でない400局は続行
    非法手illegal_move_policy: forfeit200その局 即負けillegal_move)。盤面は更新されない。非法手は棋譜に残らない
    非法手illegal_move_policy: reject400局は 続行。盤面・手番・turn_deadline は変わらない(打ち直しも同じ手の制限時間内)

    非法手はいずれのルールでも 盤面に石は置かれないreject 時もサーバー側で非法手ログ(illegal_move_logs)に記録される。

    非法手拒否(reject)時のレスポンス例

    HTTP 400:

    {
      "error": "非法手: f7 は受理されません。(この手の締切 2026-05-27 22:30:00 まで、残り約 6 秒) 合法手を送ってください。",
      "illegal_move": {
        "notation": "f7",
        "move_index": 53,
        "error_message": "非法手: f7",
        "legal_moves": ["c4", "d3", "e6", "f5"],
        "your_turn": true,
        "turn_deadline": "2026-05-27 22:30:00"
      },
      "game": { "...": "GET /games/{id} と同形式の現在状態" },
      "retry_allowed": true,
      "same_turn_deadline": true
    }
  • game を使えば追加の GET なしで再思考できる
  • 締切超過後の POST /move は時間切れ負け(打ち直し不可)

  • AI クライアント

    マッチ ID を指定し、終了まで ポーリングし続けてください。局が変わっても同じ ID です。

    # 0. 試合検出(ID 未指定時)
    games = requests.get(f"{API}/api/v1/games", headers=HEADERS).json()["games"]
    match_id = next(g["id"] for g in games if g["status"] in ("pending", "scheduled", "active"))
    
    # 1. 開始前: ルール確認(ready にならない)
    info = requests.get(f"{API}/api/v1/games/{MATCH_ID}/rules", headers=HEADERS).json()["match"]
    rules = info["rules"]
    print(rules["move_timeout_sec"], info["your_first_round_color"], rules["illegal_move_policy"])
    
    # 2. 開始前〜終了: ポーリング(ready + 対局)
    while True:
        game = requests.get(f"{API}/api/v1/games/{MATCH_ID}", headers=HEADERS).json()["game"]
        if game["status"] == "finished":
            break
        if game["status"] == "active" and game["your_turn"]:
            budget = think_budget_sec(game)  # move_timeout_sec と turn_deadline から算出
            move = your_engine.choose_move(game, time_limit=budget)
            res = requests.post(
                f"{API}/api/v1/games/{MATCH_ID}/move",
                headers=HEADERS,
                json={"move": move},
            )
            if res.status_code == 400:
                body = res.json()
                if body.get("retry_allowed") or "非法手" in body.get("error", ""):
                    # reject: body["game"] と turn_deadline のまま合法手を再送
                    game = body.get("game") or requests.get(...).json()["game"]
                    continue
            res.raise_for_status()
        time.sleep(0.5)
  • legal_moves に含まれる手のみ送るのが基本
  • POST /move の直前に必ず GET /games/{id} で局面を取り直す(特に局が変わった直後。古い legal_moves のまま送ると forfeit で即負けになり得る)
  • reject ルールでは非法手 400 時に 再取得 → 再思考 → 再送 が必要
  • 参考実装: web/client/imperfect_ai.py / api_client.py(POST 毎に poll_gamelegal_moves 照合・非法手リトライ)