Skip to content

クイックマッチ DB 設計 ​

機能概要 ​

ソロプレイヤーのオンボーディング・継続促進を目的としたランダムマッチング機能。ランダムマッチングで原則 5 人のソロプレイヤーを集め(一定時間経過後は 5 人未満も許容)、投票でシナリオを決定してマーダーミステリーイベントを成立させる。UZU Code のマルチゲーム基盤となるマッチングシステムの第一弾でもある。

スコープ ​

本ドキュメントで扱うこと ​

  • クイックマッチのキュー(待機)管理
  • マッチ成立後のルーム状態管理
  • マッチ承諾フェーズ(寝落ち等で応答しない参加者を弾くための短期承諾要求)
  • シナリオ投票(単一選択・最多票採用・投票フェーズ中の明示的退席)
  • 投票成立から既存 Event への接続
  • ペナルティ管理(承諾タイムアウト / 投票未完了 / 退席・プロセスキル等の AFK 全般)
  • ジャンルフィルタ(参加者が選択したジャンルに該当するシナリオのみマッチング対象とする)
  • 解散警告ダイアログ(自分の未承諾・未投票が原因で解散した場合に次回アプリ起動時へ警告)

扱わないこと(別実装 / 将来対応) ​

  • 人数フィルタ: day1 では実装しない。将来カラムを追加しやすい設計にする
  • レコメンドロジック: 別実装。参加者の userId リストを渡すと scenario_id が 3 件返るインタフェース。二人シナリオ除外・有料シナリオ作者の無料シナリオ優先・初心者判定などはそちら側の責務
  • キャラクター選択: 投票成立 → Event 作成後、既存の preplayscreen のキャラ選択画面へ遷移する。キャラ選択画面以降のプロセスキル等は QuickMatch ペナルティ対象外(既存 Event のスコープ)
  • 退席後の優先再キュー: 誰かの退席で解散した Room の残りメンバーを次回マッチで優先扱いする機能は day1 では実装しない(将来対応)
  • マッチング履歴テーブル: 作らない。マッチ成立時間の統計は QuickMatchWaitingUser 自身から直接算出する
  • VC インフラ: 既存の通話システムを流用(後述)

ER 図 ​

INFO

プロジェクトの慣習として、全てのテーブルには created_at / updated_at が暗黙で含まれる。ER 図では省略している。

記法の読み方 ​

Mermaid の ER 図では、線の両端にある記号でリレーションの多重度を表す。左側のエンティティから見て、右側のエンティティが何件紐づくかを示している。

記号意味例(本ページ)
||--o{1 対多(左 1 件に対し、右 0..N 件)User ||--o{ QuickMatchWaitingUser — 1 人のユーザーに対して 0..N 件の待機記録
|o--o|0..1 対 0..1(両端とも存在しないことがある)QuickMatchRoom |o--o| Event — 投票成立時のみ Event が紐づく

記号の構成要素:

  • \| = 必ず 1(必須)
  • o = 0(オプショナル)
  • { / } = 多(N)

両端の記号を組み合わせて、左右それぞれの最小・最大多重度を表現する。

カラム属性の記号 ​

エンティティ内の各カラム定義の末尾には、そのカラムの属性を示す記号が付く。

記号意味
PK主キー(Primary Key)
FK外部キー(Foreign Key)— 他テーブルへの参照
UKユニークキー(Unique Key)

末尾の "..." はカラムに付けた補足コメント(例: "nullable"、"0..2")。

エンティティ定義 ​

QuickMatchWaitingUser(キュー待ちユーザー) ​

1 回のキュー待ち〜マッチ成立までのライフサイクルを表す。同じ user が複数の待機履歴を持ちうる(過去試行の履歴 + 現在のキュー待ち)。ルームへの所属は QuickMatchParticipant 中間テーブルで表現する(ルームとは N:N)。

ライフサイクル:

段階判定方法
キュー待ちcanceled_at IS NULL かつ、対応する Event が成立していない(紐づく QuickMatchParticipant → QuickMatchRoom → Event が未作成)
マッチ成立済み紐づく QuickMatchRoom.event_id IS NOT NULL(Event 作成と同時に WaitingUser.canceled_at もセットされ、QuickMatch フェーズから卒業する)
キュー離脱canceled_at IS NOT NULL(ユーザー自身によるキャンセル)
  • 同時エントリー禁止: 「同じユーザーが同時にアクティブな WaitingUser を複数持たない」は DB の部分ユニーク制約では表現しきれない(JOIN が必要なため)。キュー登録時のトランザクションで PostgreSQL advisory lock(user_id ベース)+ 既存アクティブ WaitingUser の有無チェックにより、アプリ層で担保する
  • マッチ候補確定時刻はこのテーブルには持たず、QuickMatchParticipant.created_at(最初のマッチ試行)で表現する(1 WaitingUser が複数のマッチ試行を経る可能性があるため、WaitingUser 側に単一の matched_at を持つのは破綻する)

QuickMatchRoom(ルーム) ​

マッチ候補確定から、承諾フェーズ、投票フェーズ、投票成立または解散までを 1 レコードで表す。

status カラムは持たない。紐づく QuickMatchParticipant の状態と自テーブルのカラムの組み合わせで状態が導出できる。

状態条件
承諾フェーズ中紐づく QuickMatchParticipant のうち accepted_at IS NULL が存在、かつ event_id IS NULL AND dissolved_at IS NULL
投票中全 QuickMatchParticipant.accepted_at IS NOT NULL、かつ event_id IS NULL AND dissolved_at IS NULL
投票成立(Event 作成済み)event_id IS NOT NULL
解散dissolved_at IS NOT NULL
  • event_id は投票成立後に作成される Event への参照。投票成立時刻は Event.created_at で取得できる
  • dissolved_at は解散時刻。承諾タイムアウトによる解散・投票期限切れによる解散のどちらでもセットする
  • 投票期限は DB には持たず、created_at + 固定 N 分(コード側の定数)で算出する
  • 承諾期限は QuickMatchRoom.created_at + 15 秒(コード側の定数)で算出する。GraphQL では派生フィールド QuickMatchRoom.acceptDeadline として配信され、FE はこの値をカウントダウン基準にする
  • agora_room_id は VC 接続のチャンネル名として使用する UUID 文字列。Room 作成時(候補確定時)にアプリ層で UUID を発番してセットする。UK 制約で衝突を DB レベルで防止

QuickMatchParticipant(参加者) ​

QuickMatchWaitingUser と QuickMatchRoom を N:N で繋ぐ中間テーブル。マッチ候補確定時に作成され、承諾フェーズ・投票フェーズでの各ユーザーの挙動を記録する。

  • unique(quick_match_waiting_user_id, quick_match_room_id) で同一ルーム内の重複参加を禁止
  • accepted_at: 承諾フェーズで承諾した時刻。NULL かつ期限超過なら承諾タイムアウト
  • voted_scenario_id: 投票フェーズで選択したシナリオ。単一選択のため 1 カラムで十分(候補のいずれかであることはアプリ層で検証)
  • canceled_at: 投票フェーズ中に歯車メニューから明示的に退席した時刻

QuickMatchWaitingUserCategoryFilter(ジャンルフィルタ中間テーブル) ​

キュー待ちユーザーが選択したジャンル(ScenarioCategory)を保持する中間テーブル。複数選択可能なので 1 WaitingUser に対して 0..N 行を持つ。

  • unique(quick_match_waiting_user_id, scenario_category_id) で重複禁止
  • 行が 0 件なら「全ジャンル対象」と解釈する
  • マッチング時は、参加者が選択したジャンルに属するシナリオのみを対象にする

QuickMatchScenarioCandidate ​

投票対象のシナリオ候補。レコメンドロジック(別実装)から返された 3 件をスナップショット保存する。

  • レコメンドロジックの入力: 参加者の userId リスト
  • レコメンドロジックの出力: scenario_id 3 件
  • レコメンド側のパラメータが変わっても、候補の揺れが出ないよう DB に固定する

QuickMatchInactivityRecord(離席情報) ​

AFK(承諾タイムアウト・投票未完了・退席・プロセスキル等)発生時にバックエンドが記録するテーブル。以下 3 つの役割を兼ねる。

  1. ペナルティ違反回数のカウント: 直近 24 時間で本テーブルを COUNT することで違反回数を算出
  2. 警告ダイアログの表示対象管理: warning_dialog_shown_at IS NULL なレコードが次回アプリ起動時の表示対象
  3. 離席履歴の監査・分析
  • unique(quick_match_waiting_user_id, quick_match_room_id) で同一 (WaitingUser, Room) での二重記録を禁止(同じルームでの AFK は 1 回までしかカウントしない)
  • warning_dialog_shown_at IS NULL = 未表示 / warning_dialog_shown_at IS NOT NULL = 表示済み

QuickMatchRestriction(ペナルティ発動履歴) ​

ペナルティの発動履歴。履歴として残すため User テーブルへの直接カラム追加ではなく専用テーブルで管理する。

  • 判定時は restricted_until > now() の最新行でクイックマッチ利用可否を確認
  • フロント制御だけではネットワーク切断等で回避されるためバックエンドで管理
発動段階制限時間
初回(直近 24 時間に 4 回違反)5 分
連続 2 回目30 分
連続 3 回目12 時間
連続 4 回目以降12 時間(上限維持)

「連続」の判定はアプリ層で実装する。直近の QuickMatchRestriction の発動から一定期間内に再び違反 4 回に到達したら 1 段階上げる想定(具体的な閾値は実装時決定)。

違反回数の算出方法 ​

QuickMatchInactivityRecord テーブルに違反発生の記録が残っているので、直近 24 時間の COUNT で算出する。

sql
SELECT COUNT(*) AS violation_count
FROM quick_match_inactivity_records a
JOIN quick_match_waiting_users w ON a.quick_match_waiting_user_id = w.id
WHERE w.user_id = :user_id
  AND a.created_at > NOW() - INTERVAL '24 hours';

シンプル。従来のように複数テーブルを JOIN して条件分岐する必要がない。

機能仕様 ​

マッチング ​

  • ソロ参加のみ(duo 対応は将来拡張)
  • 同時に複数エントリー不可。QuickMatchWaitingUser のキュー登録時にアプリ層で advisory lock + 既存アクティブレコード有無チェックで担保(DB の部分ユニーク制約では表現しきれないため)
  • 対応ロケール: day1 は JP のみ / day2 で KR 拡張予定(ロケール拡張性を阻害しない設計)
  • マッチング時間帯: 平日 19–26 時 / 休日 19–26 時(= 翌02:00 まで。day1 はコード側の設定値で管理、DB には持たない)

マッチングアルゴリズム ​

成立条件 ​

  • 定員: 5 人(5 人揃った時点で即確定)
  • 時間経過による緩和: キュー待機が 30 秒を超えたら 5 人未満(下限は実装時に決定)でも成立させる

待機時間が長くなるほど人数要件が緩和されることで、少人数でもマッチが成立し、ユーザーが過度に待たされないようにする。

閾値の管理 ​

定員 / 緩和開始時間 / 緩和時の下限人数はいずれも コード側の設定値(環境変数など)で管理 し、DB スキーマには持たない。

実装方式 ​

情報源を一元化するため、基本は PostgreSQL 上で完結 させる。

フロー:

  1. ワーカー(asynq 等)が定期起動 or 新規参加イベントで起動

  2. QuickMatchWaitingUser のアクティブなキュー待ちレコード(canceled_at IS NULL かつ、関連する Event 未成立)から候補を抽出

  3. 以下のいずれかで 候補確定トランザクション:

    • 定員に達した(5 人)
    • 緩和条件に該当した(30 秒以上待機している WaitingUser が存在し、5 人未満でも成立可能な状態)

    トランザクション内で:

    • QuickMatchRoom を新規作成(agora_room_id も発番)
    • 候補メンバー全員分の QuickMatchParticipant を作成(accepted_at IS NULL、quick_match_room_id は作成した Room の ID)
  4. 承諾フェーズへ移行(詳細は「承諾フェーズ」セクション)

ワーカー落ちで途中の候補グループが消失しても、候補者はキュー待ち状態として残り、次のワーカーサイクルで再マッチングされる。

注意点 ​

  • 二重マッチング防止: 同じ WaitingUser が複数の候補グループに同時に入らないよう、DB の SELECT ... FOR UPDATE や楽観ロックで担保する
  • ジャンルフィルタとの整合: 候補全員のフィルタに該当するシナリオが存在するかをマッチング時に検証する(レコメンド API の責務と重なる部分あり)
  • ワーカー冗長化: マッチングワーカーを複数インスタンスで稼働させる場合、同じキューに対する重複処理を避けるため分散ロック(例: PostgreSQL advisory lock)が必要
  • プロセスキル検知: キュー待ち・承諾フェーズ・投票フェーズでクライアントが応答しない場合、サーバー側タイムアウトで AFK として扱い、QuickMatchInactivityRecord にレコードを作成する(専用のハートビートテーブルは作らない)
  • 観測性: 確定成立率・平均マッチング時間・承諾タイムアウト率・退席率を Metrics に出し、閾値チューニングの判断材料にする

ジャンルフィルタ ​

  • 参加者は任意のジャンル(ScenarioCategory)を複数選択してクイックマッチに参加できる
  • 選択は QuickMatchWaitingUserCategoryFilter に 1 ジャンルごとに 1 行で保存
  • ジャンル未選択(行が 0 件)の場合は「全ジャンル対象」として扱う
  • マッチング時は、選択ジャンルに属するシナリオのみを対象にする
  • 複数参加者の選択ジャンルが一致しない場合のマッチング挙動は実装時に決定する

マッチング予想時間 ​

  • 移動平均ベースで算出する(固定値は使わない)
  • 算出元データは QuickMatchParticipant.created_at - QuickMatchWaitingUser.created_at(最初にマッチ候補確定した時点までの所要時間。専用の履歴テーブルは作らない)
  • ウィンドウサイズ(例: 直近 30 分 / 直近 N 件)とサンプル不足時のフォールバックは実装時に決定する
  • 参考: LoL / Apex など商用タイトルの具体アルゴリズムは非公開だが、一般的には移動平均やキュー充足率ベースの推定が使われる

承諾フェーズ ​

マッチ候補確定直後、寝落ち・離席しているユーザーを弾くために設ける短期承諾フェーズ(UI: 画面 5)。候補確定時点で QuickMatchRoom と QuickMatchParticipant(全員分)はすべて作成済み。

  • クライアントは承諾要求を受け取り、15 秒以内 に mutation で該当する QuickMatchParticipant.accepted_at を更新
  • 承諾期限(15 秒)はコード側の定数で管理(DB には持たない)。BE は QuickMatchRoom.acceptDeadline = createdAt + 15 秒 として GraphQL 経由で配信し、FE はカウントダウン表示にこの値を使う(SSOT)
  • 期限到達時の自動解散は BE の asynq delayed task が担う。Room 作成時に TypeQuickMatchDissolveExpiredRoom task を ProcessIn(15 秒) で enqueue し、期限時に以下を原子的に処理する:
    • QuickMatchRoom.dissolved_at をセット(解散)
    • 未承諾 Participant 全員について canceled_at と対応 QuickMatchWaitingUser.canceled_at をセット(キューから外す)
    • 未承諾 Participant ぶん QuickMatchInactivityRecord を作成(離席ペナルティ判定用)
  • 期限前に全員 accepted_at がセットされた場合、asynq task は no-op で抜ける(dissolved_at IS NULL + 未承諾 EXISTS の条件付き UPDATE で atomic に skip)。そのまま投票フェーズへ移行し QuickMatchScenarioCandidate を使った投票受付が始まる(Room は既に存在)
  • FE はカウントダウン表示にのみタイマーを使い、期限切れをサーバーへ通知しない。解散は完全に BE 主導で subscription 経由で FE に伝播する

投票 ​

承諾フェーズを通過したルームでは、シナリオ投票フェーズに進む(UI: 画面 6)。

  • 投票期限: 3 分(1 シナリオあたり 1 分を目安、コード側の定数)
  • 単一選択: 1 人 1 票。投票済みシナリオは UI 上グレーアウト。別シナリオを選択し直す場合は「このシナリオで再投票する」導線を経由
  • 最多票シナリオが採用される(過半数は求めない)
  • 投票期限到達 or 全員投票済み時点で最多票が採用される
  • 投票が 1 件も集まらなければルーム解散
  • 同率解散: 全員投票済みで最多票のシナリオが複数ある(同率)場合は Event を作成せず QuickMatchRoom.dissolved_at をセットして解散する。全員投票済みで AFK ではないため QuickMatchInactivityRecord は作成しない。解散時は全 Participant を元の WaitingUser.created_at を継承した新規 QuickMatchWaitingUser として再 in queue する。クライアント側は「全員投票済みかつ Room が解散された(event_id IS NULL AND dissolved_at IS NOT NULL)」状態を同率解散と判定し、その旨をダイアログで通知する(FE 判定のため BE 側に解散理由カラムは追加しない)

退席機能 ​

投票フェーズ中に、participant は歯車メニューから 明示的に退席 できる。

  • クライアントの退席 mutation により、QuickMatchParticipant.canceled_at に時刻をセット
  • 1 人でも退席した時点で QuickMatchRoom.dissolved_at をセットしてルーム解散
  • 退席者 & 投票未完了者について QuickMatchInactivityRecord にレコード作成
  • 残った participant は次のマッチング試行に戻る(day1 では優先再キュー機能は無し)

ペナルティ ​

  • 直近 24 時間 に 4 回以上の AFK で制限を発動
  • AFK の種別(以下すべて QuickMatchInactivityRecord に 1 レコードとして記録される):
    1. 承諾タイムアウト: 承諾期限(15 秒)までに accepted_at がセットされなかった
    2. 投票未完了: 承諾済みのまま投票期限を過ぎて voted_scenario_id IS NULL のままルーム解散
    3. 明示的退席: 投票フェーズ中に canceled_at がセットされた
    4. プロセスキル: サーバー側タイムアウトで検知。上記 1〜3 と同様に AFK レコードを作成
  • バックエンドで管理(フロント制御だけではネットワーク切断等で回避可能なため)
  • 制限時間は発動回数・連続発動で段階的に変化(QuickMatchRestriction セクションの表参照)
  • 「連続」判定はアプリ層: QuickMatchRestriction の発動履歴の時系列から「前回発動からどれだけ経過したか」「累積発動回数」を計算して段階を決定する。DB スキーマに追加カラムは不要

解散警告ダイアログ ​

自分の AFK が原因でクイックマッチルームが解散された場合、次回アプリ起動時に警告ダイアログを表示する。ユーザーに原因を明示し、同じ不参加が繰り返されないよう促す目的。

QuickMatchInactivityRecord のレコードがそのまま「警告ダイアログ表示対象」を兼ねる。

解散時の処理(バックエンド) ​

ルーム解散トランザクション内で、AFK を起こした participant について QuickMatchInactivityRecord を warning_dialog_shown_at IS NULL で INSERT する(違反カウント対象にもなる)。

表示対象の抽出(アプリ起動時) ​

sql
SELECT a.id, a.quick_match_room_id
FROM quick_match_inactivity_records a
JOIN quick_match_waiting_users w ON a.quick_match_waiting_user_id = w.id
WHERE w.user_id = :user_id
  AND a.warning_dialog_shown_at IS NULL;

表示後の記録(クライアント) ​

クライアントが警告ダイアログを表示したら、mutation で該当レコードの warning_dialog_shown_at を NOW() で UPDATE する。以降このレコードは表示対象から外れる。

VC 接続 ​

既存の通話システムを流用する。Mobile は Agora、backend には LiveKit トークン生成が実装済み(フロント未統合)。

現状のチャンネル名は event_id ベース:

実装チャンネル名定義位置
LiveKit{appEnv}_{event_id}backend/internal/adapters/graph/event.resolvers.go:536
Agora{appEnv}_{eventId}_{roomId}dart/apps/mobile/lib/views/pages/play_screen/voice/rtc_engine/agora.dart:220

方針:

  • 既存の event ベース形式({appEnv}_{event_id})は 変更しない(互換性維持)
  • クイックマッチ用は QuickMatchRoom.agora_room_id(UUID 文字列)をそのままチャンネル名として使用する
  • UUID ベースなので既存の数値 ID ベースの命名とは衝突しない。prefix も不要
  • UUID はクライアント側では受動的に参照するだけで、発番はバックエンド(ルーム作成時)

関連する既存スキーマ ​

参考までに、本機能が参照 / 拡張する既存の Ent スキーマを挙げる。

  • backend/internal/ent/schema/user.go: User.restricted_use_until, User.initial_locale
  • backend/internal/ent/schema/user_relationship.go: BLOCKED 関係 — マッチング対象外判定に使用予定
  • backend/internal/ent/schema/event.go: QuickMatchRoom.event_id 参照先。変更なし
  • backend/internal/ent/schema/participant.go: 投票成立後に既存ロジックで Event の Participant を生成
  • backend/internal/ent/schema/scenario.go: 候補シナリオ / 投票対象として参照のみ
  • backend/internal/ent/schema/scenario_category.go: ジャンルフィルタで参照するマスタテーブル

決定事項サマリ ​

トピック方針
キュー待ちとルーム参加者の分離QuickMatchWaitingUser(キュー待ち)と QuickMatchParticipant(Room 参加者)を別テーブルにし、QuickMatchParticipant が WaitingUser × Room の N:N 中間テーブルを担う
ジャンルフィルタQuickMatchWaitingUserCategoryFilter 中間テーブルで複数選択を保存。未選択時は全ジャンル対象
人数フィルタday1 では実装しない。将来カラム追加しやすい構造にする
レコメンドロジック別実装。userId list → scenario_id 3 件を返すインタフェース
キャラクター選択本機能のスコープ外。投票成立 → Event 作成後、既存 preplayscreen のキャラ選択画面へ遷移
投票方式単一選択・最多票採用。QuickMatchParticipant.voted_scenario_id に書き込み
ペナルティ保持別テーブル QuickMatchRestriction で履歴管理
違反判定QuickMatchInactivityRecord テーブルの直近 24 時間の COUNT
マッチング予想時間QuickMatchParticipant.created_at - QuickMatchWaitingUser.created_at の移動平均で算出(履歴テーブルは作らない)
キュー重複禁止DB 部分ユニーク制約ではなく、キュー登録時にアプリ層で advisory lock + 既存アクティブ WaitingUser の有無チェックで担保
承諾フェーズ候補確定時に QuickMatchRoom と QuickMatchParticipant を同時作成し、asynq delayed task を ProcessIn(15 秒) で enqueue。15 秒以内に accepted_at 更新。全員承諾で投票フェーズへ。期限到達時は BE の asynq task が QuickMatchRoom.dissolved_at セット + 未承諾者の canceled_at セット + QuickMatchInactivityRecord 作成を一括で実施
投票フェーズ投票期限 3 分(1 シナリオ 1 分目安)、単一選択・最多票採用。同率時は Event 作成せず Room 解散・全員を再 in queue(AFK 扱いしない)。歯車メニューから明示的退席可(退席者が出た時点で Room 解散、AFK 記録)
マッチング人数定員 5 人。キュー待機が 30 秒超過した場合は 5 人未満でも成立(下限は実装時決定)
解散警告ダイアログQuickMatchInactivityRecord が 「違反記録」と「警告表示状態」を兼ねる。warning_dialog_shown_at IS NULL の行が次回起動時の表示対象
VC 識別子既存の {appEnv}_{event_id} 形式は変更せず、クイックマッチ用は QuickMatchRoom.agora_room_id(UUID 文字列)をそのままチャンネル名として使用
対応ロケールday1 は JP のみ。day2 で KR 拡張予定(ロケール拡張性を阻害しない設計)
マッチング時間帯平日 7–11 PM / 休日 7–11 PM(day1 はコード側の設定値で管理)