# リバーシ 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` にリプレイ整合チェック・非法手ログフィールドを追加

## ユーザー名

- **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

```json
{
  "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

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

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

---

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

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

```json
{
  "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件、開始日時の降順）

```json
{
  "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 クライアントは `status` が `pending` / `scheduled` / `active` の試合を検出して接続できる

| status | 説明 |
|--------|------|
| pending | 了承待ち（相手 B の承認前） |
| scheduled | 了承済み・開始前（双方 ready 待ち） |
| active | 対局中 |
| finished | 終了 |

---

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

```json
{
  "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` までの残り秒数マイナスマージン

```python
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` に反映される。

```json
{
  "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）

```json
{
  "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

**認証不要** — 試合予定表（テスト対局除く）

```json
{
  "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}

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

```json
{
  "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 キーが有効か確認

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

### POST /api/v1/test/start

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

```json
{
  "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

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

```json
{
  "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 由来の終局フレーム（棋譜とズレた場合に末尾に付く） |

- 着手は DB に永続保存される。**非法手そのものは棋譜（`moves`）に含まれない**
- `illegal_move` は `illegal_move_logs` テーブルから取得（v1.6 以降の試合）
- 旧データで棋譜と DB 盤面が食い違う場合、`replay_consistent: false` となり末尾に正しい終局盤面が付く

Web UI: `/matches/{match_id}`

---

## GET /api/v1/games/viewers

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

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

```json
{
  "viewers": {
    "abc123...": 5,
    "def456...": 12
  }
}
```

- 直近 `live_viewer_ttl_sec` 以内に `/live` API をポーリングした観覧者数
- 試合予定表・ダッシュボードが 5 秒ごとに更新

---

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

現在の局に着手（`status=active` かつ `your_turn=true` のとき）

```json
{ "move": "c4" }
```

| 値 | 説明 |
|----|------|
| `"c4"` 等 | 着手（`a1`〜`h8`、小文字可） |
| `"pass"` | パス（合法手がないときのみ） |

### 成功時

- HTTP **200**。レスポンス body の `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**:

```json
{
  "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 です。

```python
# 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` 照合・非法手リトライ）
