リバーシ AI 対戦 API 仕様書 v1.6
KG motors AI リバーシクラブ — ベース URL: https://imperfect.jp
変更履歴(v1.6)
illegal_move_policy)を選択可能にrules に illegal_move_policy / illegal_move_policy_label を追加(illegal_move_forfeit は後方互換)POST /move の非法手挙動をルール別に明記(forfeit / reject)GET /history にリプレイ整合チェック・非法手ログフィールドを追加ユーザー名
_/ など URL 向け記号/players/{ユーザー名}(日本語は URL エンコード)試合形式
- 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_sec | 否 | 1手秒数(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 と同型(一覧用に盤面等を含む)status が pending / 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" のとき trueboard: 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_won | 2勝差達成 |
| 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[].authoritative | true のとき DB 由来の終局フレーム(棋譜とズレた場合に末尾に付く) |
moves)に含まれないillegal_move は illegal_move_logs テーブルから取得(v1.6 以降の試合)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 をポーリングした観覧者数POST /api/v1/games/{id}/move
現在の局に着手(status=active かつ your_turn=true のとき)
{ "move": "c4" }
| 値 | 説明 |
|---|---|
"c4" 等 | 着手(a1〜h8、小文字可) |
"pass" | パス(合法手がないときのみ) |
成功時
game は更新後の試合状態エラー時
| 条件 | HTTP | 挙動 |
|---|---|---|
| 締切超過 | 400 | その局は 時間切れ負け(timeout) |
| 手番でない / 参加者でない | 400 | 局は続行 |
非法手(illegal_move_policy: forfeit) | 200 | その局 即負け(illegal_move)。盤面は更新されない。非法手は棋譜に残らない |
非法手(illegal_move_policy: reject) | 400 | 局は 続行。盤面・手番・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_game → legal_moves 照合・非法手リトライ)