AI Townのリポジトリの中身、気になりますよね!マップやエージェントの振る舞いを改良したい気持ち、よ〜く分かります。そこで、この記事ではリポジトリの中身を「誰でも分かる」ように、丁寧にザックリと解説していきます。構成要素とその処理内容を明確にすることで、改良に向けた調査をスムーズに進めましょう!
全体像
AI Townは、AIキャラクターが生活し、対話する仮想都市です。リポジトリは、この仮想都市を構築・カスタマイズするためのスターターキットを提供しています。その構成は大きく分けて以下の4つの層から成り立っています。
サーバーサイドのゲームロジック (convex/aiTown)
AI Townのデータモデル(状態、時間の経過に伴う変化、ユーザー入力への反応)を定義します。人間とエージェント両方からの入力をゲームエンジンが処理します。
クライアントサイドのゲームUI (src/)
pixi-react を使用して、ゲームの状態をブラウザにレンダリングし、人間が視覚的に楽しめるようにします。
ゲームエンジン (convex/engine)
ゲームルールを簡単に変更できるように、AI Town固有のルールとは別にゲームエンジンが用意されています。ゲームエンジンは、データベースへのゲーム状態の保存と読み込み、エンジンへの入力処理、Convex関数でのエンジン実行を担当します。
エージェント (convex/agent)
エージェントはゲームループの一部として動作し、LLMとの対話など、時間のかかる処理を行うために非同期Convex関数を起動できます。これらの関数は、別のテーブルに状態を保存したり、ゲームエンジンに入力を送信してゲーム状態を変更したりできます。内部的には、シンプルなルールベースシステムとLLMとの対話の組み合わせを使用しています。
サーバーサイドのゲームロジック (convex/aiTown)
データモデル
AI Townのデータモデルは、以下の概念で構成されます。
World (convex/aiTown/world.ts):
複数のプレイヤーが相互作用するマップを表します。
Player (convex/aiTown/player.ts):
ゲームの主要なキャラクターです。人間が理解できる名前と説明を持ち、人間のユーザーに関連付けられている場合があります。プレイヤーは、常に目的地に向かって移動しており、現在位置を持っています。
Conversation (convex/aiTown/conversations.ts):
プレイヤーによって開始され、特定の時点で終了します。
Conversation Membership (convex/aiTown/conversationMembership.ts):
プレイヤーがConversationのメンバーであることを示します。プレイヤーは、一度に1つのConversationにのみ参加でき、Conversationは現在、ちょうど2人のメンバーで構成されています。メンバーシップは、以下の3つの状態のいずれかになります。
invited:
プレイヤーはConversationに招待されていますが、まだ承諾していません。
walkingOver:
プレイヤーはConversationへの招待を承諾しましたが、会話するには遠すぎます。十分に近づくと自動的にConversationに参加します。
participating:
プレイヤーはConversationに積極的に参加しています。
スキーマ
3つの主要なテーブルカテゴリがあります。
Engine tables (convex/engine/schema.ts):
エンジン内部の状態を管理するためのテーブルです。
Game tables (convex/aiTown/schema.ts):
ゲームの状態を管理するためのテーブルです。ゲーム状態を小さく効率的に読み書きするために、AI Townのデータモデルはいくつかのテーブルに分散して保存されます。
Agent tables (convex/agent/schema.ts):
エージェントの状態を管理するためのテーブルです。エージェントは、アクション内でこれらのテーブルを自由に読み書きできます。
入力 (convex/aiTown/inputs.ts)
AI Townは入力を処理することでデータモデルを変更します。入力はプレイヤーとエージェントによって送信され、ゲームエンジンによって処理されます。入力は convex/aiTown/inputs.ts の inputs オブジェクトで定義します。
入力には以下のようなものがあります。
参加と退出:
ゲームへの参加(join)と退出(leave)
プレイヤーを特定の場所に移動させる(moveTo):
AI Townの移動はRTSゲームに似ており、プレイヤーは行きたい場所を指定し、エンジンがそこまでの経路を見つけます。
Conversation
Conversationの開始(startConversation)、招待の承諾(acceptInvite)、招待の拒否(rejectInvite)、Conversationの退出(leaveConversation)。タイピングインジケータを追跡するために、startTyping と finishSendingMessage を使用します。
エージェント入力 (aiTown/agentInputs.ts):
Conversationの内容を記憶したり、行動を決定したりするための入力です。
各入力の実装メソッドは、不変条件をチェックし、必要に応じてゲーム状態を更新します。例えば、moveTo 入力は、プレイヤーがConversationに参加していないことをチェックし、参加している場合はConversationを退出するように指示するエラーをスローし、その後、目的地の情報でパスファインディング状態を更新します。
シミュレーション
プレイヤー入力の処理時以外に、シミュレーションが時間を進めるにつれて、ゲームの状態はバックグラウンドで時間とともに変化します。例えば、プレイヤーがパスに沿って移動することを決定した場合、時間が経過するにつれてプレイヤーの位置は徐々に更新されます。同様に、2人のプレイヤーが衝突した場合、彼らはそれに気づき、障害物を避けようとしてパスを再計画します。
クライアントサイドのゲームUI (src/)
AI Townのアーキテクチャの指針となる原則の1つは、「通常のConvex」の使用法にできるだけ近づけることです。そのため、ゲームの状態は通常のテーブルに保存され、UIは通常の useQuery フックを使用してその状態を読み込み、UIにレンダリングします。
例外は履歴テーブルで、最新のステータスを useHistoricalValue フックに送り込みます。このフックは履歴バッファを解析し、スムーズな動きを実現するために時間を再生します。複数の履歴バッファ間で再生時間を同期させるために、アプリケーションのトップレベルで useHistoricalTime フックを提供し、現在の時間を追跡してコンポーネントに渡します。
また、useMutation をラップし、自動的にサーバーに入力を送信して、エンジンがそれらを処理して結果を返すまで待機する useSendInput フックも提供しています。
ゲームエンジン (convex/engine)
前のセクションで説明したAI Townのゲーム動作を踏まえ、convex/engine/abstractGame.ts の AbstractGame クラスは、実際にシミュレーションを実行します。ゲームエンジンには、以下の役割があります。
- 受信したプレイヤー入力を調整し、シミュレーションに送り込み、その戻り値(またはエラー)をクライアントに送信します。
- シミュレーションの時間を進めます。
- データベースにゲーム状態を保存し、読み込みます。
- Convexリソースを効率的に使用し、入力レイテンシを最小限に抑えながら、ゲーム動作の実行を管理します。
AI Townのゲーム動作は Game サブクラスで実装されています。
入力処理
ユーザーは insertInput 関数を通じて入力を送信します。この関数は、入力を inputs テーブルに挿入し、単調増加する一意の入力番号を割り当て、サーバーが受信した時刻で入力をタイムスタンプします。エンジンは入力を処理し、その結果を inputs 行に書き戻します。興味のあるクライアントは、inputStatus クエリを使用して入力のステータスを購読できます。
Game は、AiTown が特定の動作で実装する抽象メソッド handleInput を提供します。
シミュレーションの実行
Game クラスは、tick メソッドを使用して時間をシミュレートする方法を指定します。
- tick(now): 指定されたタイムスタンプまでシミュレーションを実行します。
- tickは tickDuration (ミリ秒) で設定可能な高頻度で実行されます。AI townではプレイヤーの移動がスムーズなので、1秒間に60tickで実行されます。
- ゲームロジックを、独立してtickできる別々のシステムに分割することをお勧めします。例えば、AI Townの tick メソッドは、パスファインディングを Player.tickPathfinding で、プレイヤーの位置を Player.tickPosition で、Conversationを Conversation.tick で、エージェントロジックを Agent.tick で進めます。
Convexのmutationを1秒間に60回実行する(これはコストがかかり、遅くなります)のを避けるために、エンジンは複数のtickを1つの_step_にまとめます。AI townでは、ステップは1秒に1回だけ実行されます。ステップの動作は以下のとおりです。
- ゲームの状態をメモリにロードします。
- 実行時間を決定します。
- 時間間隔に対して複数のtickを実行し、handleInput を使用した入力の処理と、tick を使用したシミュレーションの進行を交互に行います。
- 更新されたゲーム状態をデータベースに書き戻します。
重要な不変条件の1つは、ゲームエンジンがWorldごとに完全に「シングルスレッド」であるため、エンジンのステップの実行が時間的に重複することはないということです。競合状態や並行性を考慮する必要がないため、ゲームエンジンコードの記述がはるかに簡単になります。
ただし、この不変条件を維持するのは少し厄介です。エンジンが1分間アイドル状態で、入力があった場合、エンジンをすぐに実行したいのですが、1分経過したら実行をキャンセルする必要があります。注意しないと、入力が入力されたときにアイドルタイムアウトがちょうど期限切れになる場合、競合状態によってエンジンの複数のコピーが実行される可能性があります!
この問題への対処法は、エンジンに世代番号を保存し、時間とともに単調増加させることです。エンジンのすべてのスケジュールされた実行には、引数として期待される世代番号が含まれています。その後、エンジンの将来の実行をキャンセルしたい場合は、世代番号を1つ増やすことができます。これにより、後続の実行は、エンジンの世代番号が期待される番号と一致しないことに気づき、すぐに失敗することが保証されます。
エンジン状態管理
World、Player、Conversation、Agent クラスは、データベースからメモリへのデータのロード、ゲームルールに従ったデータの変更、データベースへの書き戻しのためのシリアル化を調整します。フローは以下のとおりです。
Convexスケジューラ
Convexスケジューラが convex/aiTown/main.ts:runStep アクションを呼び出します。
runStep アクション
- runStep アクションは、convex/aiTown/game.ts:loadWorld を呼び出して現在のゲーム状態をロードします。このクエリは Game.load を呼び出し、Worldのすべてのゲーム状態を適切なテーブルからロードし、GameState オブジェクトを返します。GameState オブジェクトには、すべてのプレイヤー、エージェントなどのシリアル化されたバージョンが含まれています。
- runStep アクションは GameState を Game コンストラクタに渡し、Game コンストラクタは、各ゲームオブジェクトのコンストラクタを使用して、シリアル化されたバージョンを解析します。例えば、new Player(serializedPlayer) は、データベース表現をメモリ内の Player クラスに解析します。
エンジン
エンジンはシミュレーションを実行し、メモリ内のゲームオブジェクトを変更します。
最終ステップ
ステップの最後に、フレームワークは Game.saveStep を呼び出します。Game.saveStep は、ステップの開始以降のゲーム状態の差分を計算し、その差分を convex/aiTown/game.ts:saveWorld mutationに渡します。
saveWorld mutation
saveWorld mutationは、差分をデータベースに適用し、削除されたオブジェクトをアーカイブする必要があるかどうかを確認し、participatedTogether グラフを更新し、スケジュールされたジョブを実行します。
エンジンはゲーム状態の唯一の変更者であるため、ステップ1〜3を繰り返さずに、一定時間ステップを実行し続けます。
ゲームエンジンが「シングルスレッド」であると想定しているのと同様に、ゲームエンジンがゲームエンジン状態を格納するテーブルを_排他的に_所有していると想定しています。これらのテーブルをプログラムで変更できるのはゲームエンジンだけなので、エンジン外のコンポーネントは入力を送信することによってのみ変更できます。
エージェントアーキテクチャ (convex/agent)
エージェントループ (convex/game/agents.ts)
エージェントは、ゲーム状態の変更を実行し、長時間実行されるリクエストやゲーム以外のテーブルへのアクセスが必要な操作をスケジュールします。一般的な流れは以下のとおりです。
- Agent.tick 内のロジックは、エージェントが他のプレイヤーの近くにいるまで待つなど、時間とともにゲーム状態を読み取って変更できます。
- LLMと会話したり、外部データを読み書きしたりする必要がある場合は、Convex関数の参照を指定して startOperation を呼び出します。一般的には internalAction です。
- この関数は、ゲームテーブルや他のテーブルから internalQuery 関数を使用して状態を読み取ることができます。
- 長時間実行されるタスクを実行し、internalMutation を使用してデータを書き込むことができます。ゲームの状態は書き込むのではなく、inputs を介して送信する必要がありま- す(前のセクションで説明)。
- 入力
以上、AI Townのリポジトリの構成と処理内容を解説してきました。この記事が、マップやエージェントの振る舞いの改良に向けた調査の一助となれば幸いです。
コメント