← 記事一覧に戻る

AI人狼ゲームのアーキテクチャ

2025-12-11 | Ayumu

AIプレイヤー同士が人狼ゲームをプレイするシステムを作った。 LLM(Gemini)が各プレイヤーの思考・発言・投票を担当し、人間は観戦者として楽しむ形式。

ポイント: 観戦者とAIプレイヤーの「情報非対称性」をどう設計するか

全体アーキテクチャ

┌─────────────────────────────────────────────────────────┐ │ Frontend (React) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Game State │ │ Log View │ │ Controls │ │ │ │ (players, │ │ (publicLog) │ │ (step,auto) │ │ │ │ phase...) │ │ │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────┘ │ POST /api/werewolf ▼ ┌─────────────────────────────────────────────────────────┐ │ API Route (Next.js) │ │ ┌─────────────────────────────────────────────────┐ │ │ │ handleStep(gameState) │ │ │ │ - フェーズに応じた処理 │ │ │ │ - Gemini API呼び出し │ │ │ │ - 状態更新して返却 │ │ │ └─────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Gemini API │ │ - 発言生成 (generateSpeech) │ │ - 投票判断 (generateVote) │ │ - 占い/護衛対象選択 │ │ - 感想生成 (generateReflection) │ └─────────────────────────────────────────────────────────┘

ステートレス設計の工夫

Vercelはサーバーレスなので、リクエスト間でメモリ上の状態を保持できない。 そこでクライアント側で全GameStateを保持し、毎回APIに送る方式を採用。

// クライアント
const handleStep = async () => {
  const res = await fetch('/api/werewolf', {
    method: 'POST',
    body: JSON.stringify({
      action: 'step',
      gameState: game.gameState  // 全状態を送る
    }),
  });
  const data = await res.json();
  setGame(data);  // 更新された状態を保存
};
メリット: サーバーレス環境でも動作、スケール容易
デメリット: 通信量が増える、クライアント側で改ざん可能(観戦専用なので問題なし)

情報非対称性の設計

人狼ゲームでは「誰が何を知っているか」が重要。このシステムでは3種類の視点がある:

視点 見える情報 見えない情報
AIプレイヤー 公開ログ、自分の役職情報 他人の役職、systemメッセージ
観戦者 全員の役職、公開ログ、systemメッセージ (なし)
人狼プレイヤー 公開ログ、仲間の人狼、人狼チャット 村人の役職

実装: メッセージタイプによる分離

// メッセージの種類
type MessageType = 'gm' | 'player' | 'system' | 'wolf_chat';

// AIプレイヤーに渡す情報(systemを除外)
export function getPlayerView(game: GameState, playerId: string) {
  // systemメッセージを除外
  const filteredLog = game.publicLog.filter(m => m.type !== 'system');

  return {
    publicLog: filteredLog,
    privateInfo,  // 役職に応じた秘密情報
    wolfChat      // 人狼のみ
  };
}

type: 'system'のメッセージは観戦者向けの状況整理。 AIプレイヤーのプロンプトには含まれないので、「神視点のネタバレ」を見せずに済む。

フェーズ管理

ゲームは以下のフェーズで進行:

night → day → vote → night → day → vote → ... → reflection → result │ │ │ │ │ │ └─ 投票処理、処刑 │ │ └─ 挙手制議論(1人ずつ発言) │ └─ 占い/護衛/襲撃 └─ 感想戦(1人ずつ発言)

議論フェーズの「挙手制」

議論では「誰から発言するか」を決める必要がある。 全員に「発言したいか?」を聞いて、挙手した人だけが発言する方式を採用。

// 挙手判定
export async function runRaiseHandsPhase(game: GameState) {
  const eligiblePlayers = getAlivePlayers(game)
    .filter(p => p.id !== game.lastSpeaker);  // 連続発言防止

  // 全員に並列で挙手判定
  const handsResults = await Promise.all(
    eligiblePlayers.map(async p => ({
      player: p,
      wants: await checkWantToSpeak(p, game),
    }))
  );

  const speakers = handsResults.filter(r => r.wants).map(r => r.player);
  shuffleArray(speakers);  // ランダム順
  game.pendingSpeakers = speakers.map(p => p.id);
}

文字数制限による議論終了

人狼ゲームでは「議論時間」が重要。このシステムでは時間の代わりに文字数制限を採用。

// 生存者数 × 300文字 が上限
export function getDayCharLimit(game: GameState): number {
  const aliveCount = getAlivePlayers(game).length;
  return aliveCount * 300;
}

// 発言のたびにカウント
game.dayCharCount += speech.length;

// 上限に達したら投票へ
if (game.dayCharCount >= charLimit) {
  advancePhase(game);
}
なぜ300文字?
人狼の議論時間は人数×30〜60秒が目安。発話速度は約300〜400文字/分。 よって人数×300文字で約1人30秒相当の議論量。

並列 vs 直列処理

LLM呼び出しは遅い。並列化できる場面では積極的にPromise.allを使う。

並列処理が有効な場面

  • 挙手判定(全員に同時に聞く)
  • 投票(全員同時に投票)
  • 夜の行動(占い/護衛/襲撃)

直列処理が必要な場面

  • 議論中の発言(前の発言を見て反応)
  • 感想戦(1人ずつ順番に)
落とし穴: 感想フェーズを並列で実装すると、全員分のAPI呼び出しが終わるまで何も表示されない。 1人ずつ表示したい場合は直列処理にする必要がある。

観戦者向け状況整理

観戦者は全員の役職を知っているが、ゲームが進むと状況が複雑になる。 そこで各日の終わりに「観戦者メモ」を自動生成。

export function generateObserverSummary(game: GameState): void {
  const lines: string[] = [];
  lines.push(`【観戦者向け状況整理】`);
  lines.push(`生存者: ${alivePlayers.length}人(人狼: ${aliveWolves.length}人)`);

  // 占い結果のまとめ
  if (game.seerResults.length > 0) {
    const seerSummary = game.seerResults.map(r => {
      const target = game.players.find(p => p.id === r.target);
      return `${target?.name}=${r.isWolf ? '黒' : '白'}`;
    }).join('、');
    lines.push(`占い結果: ${seerSummary}`);
  }

  // 偽占い師COの検出
  const fakeSeerCOs = game.players.filter(p =>
    p.role !== 'seer' &&
    game.publicLog.some(m =>
      m.speaker === p.name &&
      m.content.includes('占い師CO')
    )
  );
  if (fakeSeerCOs.length > 0) {
    const fakers = fakeSeerCOs.map(p =>
      `${p.name}(${ROLE_NAMES[p.role]})`
    ).join('、');
    lines.push(`偽占い師CO: ${fakers}`);
  }

  // type: 'system' で記録(AIプレイヤーには見えない)
  addMessage(game, 'system', 'system', lines.join('\n'));
}

プロンプト設計のポイント

1. ルールと戦略を分離

最初はルールと戦略を混ぜていたが、AIが混乱した。 「人狼は人狼を襲撃できない」はルール、「占い師COは状況を見て判断」は戦略。

2. 全役職の戦略を共有

人狼ゲームでは「他の役職がどう動くか」を推測することが重要。 そのため、AIプレイヤーには全役職の基本戦略を教える(自分の役職だけでなく)。

3. 発言長の調整

// 発言プロンプトの例
const prompt = `
あなたは${player.name}(${ROLE_NAMES[player.role]})です。

【発言のルール】
- 1〜2文で短く(長い演説は怪しまれます)
- 推理や質問を入れる
- 過度な敬語は不要

【議論残り】約${charRemaining}文字
`;
注意: maxOutputTokensを小さくしすぎると発言が途中で切れる。 逆に大きすぎると演説調になる。2000程度で「短く」と指示するのが良いバランス。

まとめ

人狼ゲームは「情報の非対称性」と「推理・駆け引き」が面白さの核心。 AIにそれを再現させるには、何を見せて何を隠すかの設計が重要だった。