🤼

【Rust】カバディとDDD - 前編

2023/02/14に公開約6,300字

カバディの配信用スコアラーを開発します。
一般的なスポーツ中継の画面の隅で点数や状況を表示している、アレです。
長くなるので、記事を前編後編として切っています。

想定する読者

  • 未来のカバディスト

おことわり

  • クリーンアーキテクチャおよびDDDの真髄を学べるコンテンツとは異なります。
  • 今回の開発に便利な概念のみ、いい感じに都合よく設計に取り入れました。こういう事例もあるんだな、ぐらいに捉えて頂ければ幸いです。
  • 設計観念の詳説を避けています。既に良記事がたくさんあるためです。

https://qiita.com/nrslib/items/a5f902c4defc83bd46b8

https://www.nuits.jp/entry/easiest-clean-architecture-2019-09

技術スタック

クリーンアーキテクチャの解釈

有名な図を慣習に倣って引用します。[1]

要するに設計する上で大事なことは、以下となります。

  1. カバディの競技規則がドメイン
  2. ドメインは独立して確立しており、データベースやアプリケーションフレームワークの仕様がどうなろうと無関係

例えば、利用していたライブラリが廃止されても、少なくともカバディのルールが変わることはありません。

カバディのドメイン知識

カバディは主にインドを中心とした南アジア諸国で数千年の歴史を持つ、「狩り」をモチーフにしたスポーツです。この競技に使用されるコートを下図に示します。[2]

競技のルール

それぞれ7人で構成された2つのチームがハンティングを見立てて点数を競います。
このコートの中で、敵チームを攻める「レイド」を交互に繰り返すのがカバディの基本サイクルです。

攻めるといっても、7人全員で敵陣に乗り込むわけではありません。1人だけです。狩人役の7人をアンティ(Anti)、獣役の1人をレイダー(Raider)と呼びます。

得点方法

レイダーはアンティに「タッチ」して自陣に無事に帰還できれば、タッチできたアンティの数が点数として獲得します。
かたや、アンティはレイダーを捕獲して「帰還不可」にすると1点を得点します。
アンティ視点だと「タッチされそうなら逃げる」「捕まることができそうなら捕まえる」という役割が重なった状況下にいます。

レイドのルール

レイドの制限時間は30秒です。
レイド中は「カバディ」という言葉[3]を発し続けます。これをキャントといいます。カバディ…

もし、カバディ以外の言葉発してしまう、もしくは何も発言していないとアウトになります。
また、

  • レイダーがアンティにタッチしなかった
  • アンティは逃げただけで捕まえなかった

この場合は、スコア変動なしで「エンプティレイド」として記録されます。

このエンプティレイドが許されるのは連続2回までです。
エンプティレイドを2回連続した場合は、次のレイドがDo-or-die Raidになります。サードレイドとも呼ばれ、この時にエンプティレイドをしてしまうとアウトとなります。

Do-or-dieは、3回のレイドごとにいずれかのチームのスコアを取得することを保証することで、ゲームの得点率に寄与しています。
要するに、盛り上がります。

アウトと人数変動

「アウト」になるプレイヤーは以下のようなケースです。

  • レイダーにタッチされて得点源とされたアンティ全員
  • アンティに捕獲され、自陣に帰還できなかったレイダー
  • コートの外に出てしまったレイダーもしくはアンティ

アウトになると、コート外(シッティングブロック)で待機状態となります。つまり、退場です。退場だけでは成立しませんので、コートに復帰する方法があります。

敵をアウトにすることです。

  • レイドで敵アンティをアウトにすると、アウトにしたアンティの人数分、待機中の味方をコートに戻すことが出来ます。
  • アンティで敵レイダーをアウトにすると、待機している味方1人を戻せます。

このように、点数の増減と人数の増減はリンクしています。

コートの扱い
  • ミッドライン
    • この線が自陣と敵陣を隔てます。レイダー以外は超えることが許されません。
    • 指1本でもいいので、体の一部が自陣に戻れば「帰還成功」となります。
  • ボークライン
    • 「エンプティレイド」にもノルマはあります。ボークラインを超えることです。
    • ボークラインを超えないエンプティレイドは認められず、レイドアウトとなります。
  • ボーナスライン
    • アンティが6人もしくは7人の場合、この線をレイダーが超えて自陣に戻ると、タッチの有無に関わらず1得点します。
  • ロビー
    • 実は、ロビーはコート外であり、入ってしまうとラインアウトになります。
    • ロビーはストラグル中のみコート内として認められる領域です。ストラグルとは「レイダーがアンティにタッチして自陣に戻るまでの間の時間」のことです。

エンティティを組み立てる

本題ですね。
カバディの試合の構成要素は大まかに分けると、ドメインモデルにあたるエンティティは4つです。

  • Team:スコアとプレイヤーを所有
  • Player:個人情報とプレイ中orアウト中の情報を保持
  • Raid:毎サイクル必ず発生する10個のシナリオを記録
  • Kabaddi:局面管理

これらの依存方向を整理したクラス図が下図です。

最初はカバディエンティティが無かった構成でした。
レイドエンティティにラウンド数を持たせれば、何巡目のレイドだったのか分かると考えたためです。しかし、実際のレイドで「今が何巡目か」など関心はありません。レイドは全て単体で存在し、シーケンシャルな属性ではないと考えました。そこに関心があるのはスコアラーだけです。
そこで、以下を連続的に記録するカバディエンティティを導入しました。

  • 残り時間何分で
  • レイダーは[ xxx さん]で
  • アンティは [ 複数人 ]の状況で
  • [ 選択されたシナリオ ]というレイド結果がうまれた

ここで、スコアは管理する必要はありません。サイクルエンティティが管理するレイドエンティティがスコアを所有しているためです。

上記の図はいろいろ省略していますが、開発を進めながら修正していきます。
実装ではここにルールを結晶化させていきます。

ユースケースを組み立てる

カバディというゲームをシステマティックな側面で見たとき、「各チームが下記10種のシナリオが必ず発生するサイクルを交互に繰り返す」規則性が発見できます。これさえ押さえれば、点数加算のログが取れます。

  • レイドアウト
    • タックル(アンティ要因)
    • スーパータックル
    • ラインアウト
    • サードレイドアウト
    • キャントアウト
    • ノットエントリーボークライン
  • レイド成功
    • タッチ
    • ボーナス
    • タッチアンドボーナス
    • エンプティ

このいずれかのシナリオを発生事象として記録できたときにUIも更新できれば、スコアボードアプリのユースケースを達成します。

インフラストラクチャー層

今のところ、サーバ立ててAPIがどうのこうのみたいな雰囲気じゃないので、ORMは使わず生SQL書こうと思います。ラッパーはこれを使わせていただきます。

ER図

SQLite3を使用してます。

DBクライアントはDBeaverを愛用してます。たまにA5M2に浮つきます。

UIの骨子

要するにどちらのチームで何が起きたか分かればいいので、入力画面は下図のようなイメージです。
「Score Board」の領域のみ配信映像に被せる感じです。

画面遷移

ここも大したことないですが、初期画面に試合追加ボタンおよび追加された試合の一覧、それとサイドバーからマスタ設定、ができればOKとします。試合追加ボタンで先ほどのレコーディング画面が出るといいですね。

実装 - Entity

バリデーション管理はvalidator、エラー管理はanyhowを利用しています。

Cargo.toml
validator = { version = "0.16", features = ["derive"] }
anyhow = "1.0.69"

スコアボードのことは忘れて、ただカバディのことだけに集中してエンティティを定義します。

まずはプレーヤーエンティティです。

src/models/player/player_entity.rs
#[derive(Debug, Clone, PartialEq, Eq, Validate)]
pub struct Player {
    pub id: PlayerId,
    pub name: PlayerName,
    pub height: PlayerHeight,
    pub weight: PlayerWeight,
    pub number: PlayerNumber,
    pub status: PlayerStatus,
}
impl Player {
    pub fn new(
        id: PlayerId,
        name: PlayerName,
        height: PlayerHeight,
        weight: PlayerWeight,
        number: PlayerNumber,
        status: PlayerStatus,
    ) -> Result<Self> {
        Ok(Self {
            name,
            id,
            height,
            weight,
            number,
            status,
        })
    }
}

コンストラクタしか書いてません。
たぶん開発進めながらドメイン知識を足していきます。
ただし、体重は85Kg未満です!などのドメイン知識[4]は、プレーヤーのふるまいではなくPlayerWeightオブジェクトのバリデーションに任せます。

src/models/player_weight.rs
use validator::Validate;

#[derive(PartialEq, Eq, Clone, PartialOrd, Ord, Debug, Validate)]
pub struct PlayerWeight {
    #[validate(range(max = 85))]
    value: u8,
}

こんなノリで値オブジェクトを量産します。
リポジトリ用のトレイトは以下です。

src/models/player_repository.rs
#[async_trait]
pub trait PlayerRepository {
    async fn save(&self, player: Player) -> Result<()>;
}

fetch_by系はfetch_by_nameや、fetch_by_idメソッドなど、ユースケースによって定義が増えてしまうので、ドメイン層から出してクエリサービスとしてユースケース層に定義します。

当記事は後編に続きます。

脚注
  1. 出典:Clean Coder Blog ↩︎

  2. 出典:What are the measurements of Kabaddi court? ↩︎

  3. 意味はないらしいです。 ↩︎

  4. カバディの公式戦では85Kgを超えてはならないという体重制限が課されます。 ↩︎

Discussion

ログインするとコメントできます