デジタルでリアルなガチャを再現してみる

# デジタルのガチャは青天井
## ガチャ、ガチャを回す
「ガチャ」と言えば、硬貨を入れて景品が出てくる遊戯、転じてFree-to-Playのゲームではもはや欠くことのできない収入源ですね。ガチャを遊ぶためのゲーム内通貨も、クリスタルだろうがジュエルだろうが「石」の愛称で親しまれ、

- 「詫び石」（メンテナンスのお詫びなどで運営が通貨を配布する）
- 「石を割る/砕く/溶かす」（通貨を消費する。右に行くほど規模が大きく、または費用対効果が低い）

などと言った術語は、各ゲームの枠を超えて、ソシャゲーマーの共通言語となっています。昨日は「石には鮮度がある」という格言も生まれていました。
[重課金な後輩がいるんだけど... - arukasさんのツイート](https://twitter.com/arukas_lorso/status/939760093217792001)

ちなみに『ガシャポン』はバンダイの登録商標で、それゆえバンナム系のゲーム（デレステとか）では「ガシャ」の呼称が使われています。他所が言ったら怒られる気がします。

## 課金兵がハマるガチャ沼
さてそんなガチャですが、1ヶ月ほど前に話題になったツイートがあります。
[今週のジャンプは結構ためになる - ナッポーさんのツイート](https://twitter.com/FGO19991030/status/930367358157275136)

リアルのガチャでは、中に入っている景品の総数は決まっていますので、「出るまで回す」ことは可能です。欲しいものが最後の1個まで残ろうが、涸れるまで回せば確実に入手できます。

一方でデジタルのガチャはと言えば、上掲の漫画の一コマの通り、変化しない母集団の中から、設定された確率で引き当てる必要があります。イメージとしては、景品のパネルがたくさん並んでいて、当選したパネルに描かれたもの（のクローン）が裏から出てくる、とかそんな感じ。なので、「出るまで回す」は実質**青天井**となります。

以上のように、ガチャ自体にゴールは存在しないのですが、救済などの形で天井が設けられることはあります。グラブルの『蒼光の御印』やFGOの『無記名霊基』など。
年の瀬なのでついでに軽く振り返ると、2016年は[アンチラ事件](https://kamigame.jp/%E3%82%B0%E3%83%A9%E3%83%96%E3%83%AB/%E3%82%B2%E3%83%BC%E3%83%A0%E7%9F%A5%E8%AD%98/%E7%82%8E%E4%B8%8A%E4%BA%8B%E4%BB%B6.html#%E3%82%A2%E3%83%B3%E3%83%81%E3%83%A9%E4%BA%8B%E4%BB%B6)に始まり、ガチャの沼っぷりが危険視され、見直される年だったのではないでしょうか。
2017年はマギレコ、アズレン、デスチャなど有名タイトルにも「月パス」があり、ガチャ以外のマネタイズの試みが方々で見られるようになった年かも。日本のガラパゴスなソシャゲ業界に、昔MMORPGで鳴らした大陸資本が本格進出してきた、と言っていいかもしれませんが。

## デジタルとリアルの違い
デジタルとリアルのガチャの違いは、大きく二つあると言えます。

- 抽選の母数

上述の通り、リアルのガチャは筐体の中に入っているカプセルが抽選の母数になり、参加する度に減っていきます。
デジタルのガチャは、マスタデータをコピーしてユーザーの手元に置いているだけなので、どれだけ参加しても母数が減ることはありません。

- 一度に抽選に参加できる人数

リアルのガチャは、硬貨を投入してから景品を獲得するまでの、一連の抽選プロセスの間に、他者が抽選に介入することはできません。筐体の蓋を開けて中身を弄ったりしない限り。
一方デジタルでは、ふつうは一度に複数人が、それぞれの環境で抽選に参加できるようになっています。

この二点はそれぞれ独立した特徴ではなく、
「抽選の母数がないから、一度にどれだけの人数が参加してもよい」（涸れたり壊れたりすることなくサービスが継続される）
「一度に参加できる人数が一人なら、抽選の母数を設定してもよい」（介入の心配がなく、抽選の整合性が保たれる）
と、それぞれがその抽選形式を提供する構造上の要因のようになっています。

実際、デジタルであっても、「一度に参加できる人数が一人」であることが保証されれば、抽選の母数を設定することはできます。「ボックスガチャ」と呼ばれるものがそれで、ユーザーが個人のボックス（抽選の母体）を所有し、そのボックスに対して抽選をかけていく仕組みです。他のユーザーがその抽選に介入することがあり得ないため、ユーザーは「出るまで回す」ことができます。ただし、ゴールが決まっているのと、母数が決まっているからこそはじめのほうは外ればかりが出る印象が拭えないため、マネタイズには向かず、主としてゲーム内のイベントのギミックで用いられます。

では、

- 抽選の母数が定まっており
- 一度にどれだけの人数が参加してもよい

そんないいとこどり？のガチャは、果たして作れるのでしょうか。
それを作ってみようと試みるのが本稿になります。

# デジタルでリアルなガチャを作ってみる
## プレゼントを決める

せっかくなので、クリスマスらしい景品を用意しましょう。

| ★3(R) | ★4(SR) | ★5(SSR) |
|:------:|:-------:|:--------:|
| ベル | トナカイ | サンタクロース |
| キャンドル | ケーキ | ツリー |
| 靴下 | そり | |
| リース | | |
| ターキー | |

クリスマスを彩るものを10個挙げてみました。だいたい石で回せるガチャというのは、★3(R)～★5(SSR)の中から抽選される、と言ってよいので、レアリティも振っておきます。
キャラガチャというよりはアバターガチャっぽくなりましたが、これを使ってガチャを作ることにします。

## まずはふつうのデジタルガチャから
冒頭のツイートに則り、目玉景品である★5(SSR)の当選確率は1%としましょう。
だから「サンタ」と「ツリー」はそれぞれ0.5%で……としてもいいのですが、景品が増えた時に★5の当選確率が変わったりしてしまっては困るので、「レアリティ毎の当選確率を定義するテーブル」と「同レアリティ内の各景品の当選確率を定義するテーブル」を用意します。[^1]

- レアリティ毎の当選確率（`master_gacha_rare_rate`）

| rare| rate |
|:--|--:|
| 5 | 10 |
| 4 | 100 |
| 3 | 890 |

- 同レアリティ内の各景品の当選確率(`master_gacha_rare_item_rate`)

| rare | number | item | rate |
|:--|:--|:--|--:|
| 5 | 1 | サンタクロース | 500 |
| 5 | 2 | ツリー | 500 |
| 4 | 1 | トナカイ | 333 |
| 4 | 2 | ケーキ | 333 |
| 4 | 3 | そり | 334 |
| 3 | 1 | ベル | 200 |
| 3 | 2 | キャンドル | 200 |
| 3 | 3 | 靴下 | 200 |
| 3 | 4 | リース | 200 |
| 3 | 5 | ターキー | 200 |

よく見る「レアリティ内の確率は小数点第1位までの表示であり、合計がレアリティの確率と異なる場合があります」といった注意書きは、このようなデータの持ち方でも起こり得ることがわかります。
10%の確率としている★4(SR)に3件のアイテム登録があり、全体を100%とした時にはそれぞれ3.33%, 3.33%, 3.34%となりますが、これを「3.3%」と表記すると、「★4の排出確率は10%だが、★4の各景品の確率の合計は9.9%」となるわけですね。

次に処理を書きますが、「単発」と「10連」があるのが今の主流だと思いますので、どちらでも抽選できるようにしておきます。[^2]

```php:Gacha.php
/**
 * ガチャを回す
 * @param int $playCount 参加回数(1=単発,10=10連/default:1)
 * @return array 抽選結果を格納した配列
 **/
public function playGacha( $playCount = 1 )
{
	// $gachaIdの定義は省略

	$result = array();

	for( $i = 0; $i < $playCount; $i++ ) {

		// レアリティの確率テーブルを取得
		$rareRateList = self::getRareRateList( $gachaId );

		// レアリティを抽選
		$gachaRare = self::getRandom( $rareRateList );


		// 同レアリティのアイテムテーブルを取得
		$itemRateList = self::getRareItemRateList( $gachaId, $gachaRare->getRate() );

		// アイテムを抽選
		$item = self::getRandom( $itemRateList );
		$result[] = $item;
	}

	return $result;
}


/**
 * master_gacha_rare_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_rateのオブジェクトを格納した配列
 **/
public static function getRareRateList( $gachaId )
{
	$list = Db_Sql_Master_Gacha_Rare_Rate::selectList( $gachaId );

	return $list;
}

/**
 * master_gacha_rare_item_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_item_rateのオブジェクトを格納した配列
 **/
public static function getRareItemRateList( $gachaId, $rare )
{
	$list = Db_Sql_Master_Gacha_Rare_Item_Rate::selectList( $gachaId, $rare );

	return $list;
}

/**
 * 確率を計算して抽選
 * @param array $list オブジェクトを格納した配列
 * @return obj 配列から抽選されたオブジェクト
 **/
public static function getRandom( $list )
{
	$result = null;

	$rateBase = 0;
	$rateSum  = 0;

	// 確率母数を計算
	foreach( $list as $record ) {
		$rateBase += $record->getRate();
	}

	// 確率を生成
	$rand = mt_rand( 1, $rateBase );

	// 抽選
	foreach( $list as $record ) {
		if( $record->getRate() <= 0 ) {
			continue;
		}

		$rateSum += $record->getRate();
		if( $rand <= $rateSum ) {
			$result = $record;
			break;
		}
	}

	return $result;
}
```

```php:Db_Sql_Master_Gacha_Rare_Rate.php
/**
 * レコード一覧を取得
 *
 * @param int $gachaId ガチャID
 * @return array [Db_Data_Master_Gacha_Rare_Rate]
 */
public static function selectList($gachaId)
{
    $result = array();

    try {
        GlobalVar::getDb()->openMasterSelect();
        $table  = new Db_Dao_Master_Gacha_Rare_Rate(GlobalVar::getDb()->getMaster());
        $select = $table->select()->from($table)
            ->where(Db_Dao_Master_Gacha_Rare_Rate::GACHA_ID.' = ?', $gachaId);

        $rows = $table->fetchAll($select);
        if (!empty($rows)) {
            foreach ($rows as $row) {
                $rObj = new Db_Data_Master_Gacha_Rare_Rate();
                $rObj->setRow($row);
                $result[] = $rObj;
            }
        }

    } catch (Exception $e) {
        throw $e;
    }

    return $result;
}
```

```php:Db_Sql_Master_Gacha_Rare_Item_Rate.php
/**
 * レコード一覧を取得
 *
 * @param int $gachaId ガチャID
 * @param int $rare レアリティ
 * @return array [Db_Data_Master_Gacha_Rare_Item_Rate]
 */
public static function selectList($gachaId, $rare)
{
    $result = array();

    try {
        GlobalVar::getDb()->openMasterSelect();
        $table  = new Db_Dao_Master_Gacha_Rare_Item_Rate(GlobalVar::getDb()->getMaster());
        $select = $table->select()->from($table)
            ->where(Db_Dao_Master_Gacha_Rare_Item_Rate::GACHA_ID.' = ?', $gachaId)
            ->where(Db_Dao_Master_Gacha_Rare_Item_Rate::RARE.' = ?', $rare);

        $rows = $table->fetchAll($select);
        if (!empty($rows)) {
            foreach ($rows as $row) {
                $rObj = new Db_Data_Master_Gacha_Rare_Item_Rate();
                $rObj->setRow($row);
                $result[] = $rObj;
            }
        }

    } catch (Exception $e) {
        throw $e;
    }

    return $result;
}
```

例外処理とかその辺は脇に置いて、大枠こんな感じで、抽選された`master_gacha_rare_item_rate`オブジェクトが格納された配列を取得することができます。

ではここから、徐々にリアルガチャに寄せていくことといたしましょう。

# リアル化その1.「母数を定める」
## 在庫を設定する

まずは各景品に在庫を設定します。確率を千分率にしましたから、全体で1,000個にすることにします。
それぞれのテーブルに`stock`カラムを追加します。

- `mst_gacha_rare_rate`

| rare | rate | stock |
|:--|--:|--:|
| 5 | 10 | 10 |
| 4 | 100 | 100 |
| 3 | 890 | 890 |

- `mst_gacha_rare_item_rate`

| rare | number | item | rate | stock |
|:--|:--|:--|--:|--:|
| 5 | 1 | サンタクロース | 500 | 5 |
| 5 | 2 | ツリー | 500 | 5 |
| 4 | 1 | トナカイ | 333 | 33 |
| 4 | 2 | ケーキ | 333 | 33 |
| 4 | 3 | そり | 334 | 34 |
| 3 | 1 | ベル | 200 | 178 |
| 3 | 2 | キャンドル | 200 | 178 |
| 3 | 3 | 靴下 | 200 | 178 |
| 3 | 4 | リース | 200 | 178 |
| 3 | 5 | ターキー | 200 | 178 |

処理のほうにも、「その時の在庫を見て、在庫がないものは抽選から外す」とする記述を加えます。
そのためには、参加したユーザーが何をどれだけ獲得したかを記録するテーブルが必要です。

- ユーザーのガチャ獲得履歴（`user_gacha_log`）

| user_id | rare | number | count |
|:--|--:|--:|--:|
| 1 | 5 | 2 | 2 |
| 1 | 3 | 1 | 2 |
| 2 | 5 | 1 | 2 |
| 2 | 5 | 2 | 2 |
| 2 | 4 | 1 | 3 |
| 2 | 3 | 1 | 2 |
| 3 | 5 | 2 | 1 |

たとえばこんな感じだと、★5のnumber2、すなわち「ツリー」は、もう在庫がないということになります。
ですから、レアリティの抽選で★5が当選した時でも、「ツリー」は抽選から外さなくてはいけません。
同様に、「サンタクロース」も在庫がなくなってしまったら、今度はレアリティの抽選で★5が当選すること自体を防ぐ必要があります。
マスタデータをupdateするのは望ましくないので、ソースの中で獲得履歴と照合することにします。

```php:Gacha.php
/**
 * master_gacha_rare_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_rateのオブジェクトを格納した配列
 **/
public static function getRareRateList( $gachaId )
{
	$list = Db_Sql_Master_Gacha_Rare_Rate::selectList( $gachaId );

	// レアリティ毎にユーザーの獲得合計と照合して、在庫が切れていたら配列から除く
	foreach( $list as $key => $data ) {

		// ユーザーの獲得履歴を取得
		$logCount = Db_Sql_User_Gacha_Log::sumCount( $gachaId, $data->getRare() );

		if ( $data->getStock() <= $logCount ) {
			unset( $list[$key] );
		}
	}

	return $list;
}

/**
 * master_gacha_rare_item_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_item_rateのオブジェクトを格納した配列
 **/
public static function getRareItemRateList( $gachaId, $rare )
{
	$list = Db_Sql_Master_Gacha_Rare_Item_Rate::selectList( $gachaId, $rare );

	// ナンバー毎にユーザーの獲得合計と照合して、在庫が切れていたら配列から除く
	foreach( $list as $key => $data ) {

		// ユーザーの獲得履歴を取得
		$logCount = Db_Sql_User_Gacha_Log::sumCount( $gachaId, $rare, $data->getNumber() );

		if ( $data->getStock() <= $logCount ) {
			unset( $list[$key] );
		}
	}

	return $list;
}
```

```php:Db_Sql_User_Gacha_Log.php
/**
 * 累計獲得数を取得
 *
 * @param int $gachaId ガチャID
 * @param int $rare 景品レアリティ
 * @param int $number 景品の並び
 * @return array 獲得数の配列
 */
public static function sumCount($gachaId, $rare, $number = null)
{
	$result = array();

    // $dbの定義は省略

    try {
        $table  = new Db_Dao_User_Gacha_Log($db);
        $select = $table->select()->from(
            $table,
            'COALESCE(SUM('.Db_Dao_User_Gacha_Log::COUNT.'), 0) AS sum_count'
        )
        ->where(Db_Dao_User_Gacha_Log::GACHA_ID.' = ?', $gachaId)
        ->where(Db_Dao_User_Gacha_Log::RARE.' = ?', $rare);

        if (!is_null($number)) {
            $select->where($table->getColumn(Db_Dao_User_Gacha_Log::NUMBER).' = ?', $number);
        }

        $row = $table->fetchRow($select);
        if (!is_null($row)) {
            $count = $row['sum_count'];
        }

    } catch (Exception $e) {
        throw $e;
    }

    return $count;
}
```

## 確率を在庫数で管理する

これで、在庫が0になった景品は排出されることがなくなりましたが、まだリアルのガチャには近いようで遠い状態です。
リアルのガチャでは、「最初から最後まで回し続けた時の、n回目でAが当たる確率」というのは均等であると言えます（回を進めれば進めるほど、残りの中にAがあれば当選確率は高くなっていますが、一方でそれまでに獲得されてしまっている確率も高くなっている）。
しかし、「ある1回」に注目した時、特定の景品が当たる確率は、母数が少なくなればなるほど高まります。
言い換えれば、リアルのガチャは、すべてが「1」の確率を持った景品で構成されていて、n個の中から特定の景品が当たる確率は、景品の種類に依らず、1/nであると言うことができます。

対してここまでの実装では、「在庫がなくなったら当選しなくなる」のは再現できましたが、「在庫がなくなるまではデジタルな確率で抽選され続ける」ようになっています。
例えば、参加したユーザーが★3ばかり引き続けて、★4・★5がまるまる残っているような状態でも、★3の在庫が涸れるまでは、マスタデータに設定されている「89%」の確率で、★3が当選してしまいます。

なので、`rate`カラムの値ではなく、在庫の数が、そのまま確率になるようにしてしまいましょう。

```php:Gacha.php
/**
 * master_gacha_rare_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_rateのオブジェクトを格納した配列
 **/
public static function getRareRateList( $gachaId )
{
	$list = Db_Sql_Master_Gacha_Rare_Rate::selectList( $gachaId );

	// レアリティ毎にユーザーの獲得合計と照合して、在庫が切れていたら配列から除く
	foreach( $list as $key => $data ) {

		// ユーザーの獲得履歴を取得
		$logCount = Db_Sql_User_Gacha_Log::sumCount( $gachaId, $data->getRare() );

		// 在庫を確率として扱う
		$data->setRate( $data->getStock() - $logCount );

		if ( $data->getRate() <= 0 ) {
			unset( $list[$key] );
		}
	}

	return $list;
}

/**
 * master_gacha_rare_item_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_item_rateのオブジェクトを格納した配列
 **/
public static function getRareItemRateList( $gachaId, $rare )
{
	$list = Db_Sql_Master_Gacha_Rare_Item_Rate::selectList( $gachaId, $rare );

	// ナンバー毎にユーザーの獲得合計と照合して、在庫が切れていたら配列から除く
	foreach( $list as $key => $data ) {

		// ユーザーの獲得履歴を取得
		$logCount = Db_Sql_User_Gacha_Log::sumCount( $gachaId, $rare, $data->getNumber() );

		// 在庫を確率として扱う
		$data->setRate( $data->getStock() - $logCount );

		if ( $data->getRate() <= 0 ) {
			unset( $list[$key] );
		}
	}

	return $list;
}
```

# リアル化その2.「在庫の整合性を保つ」
前項でそれなりにしっかりした「リアル風」デジタルガチャが出来上がりました。
ユーザー単位で参加状況を管理する「ボックスガチャ」であれば、ここで完成です。

しかし、本稿で目指していたのは「何人が同時に参加してもよい」ガチャでした。そこに当てはめてみると、在庫を管理するようにしたことで、デジタル故の大きな落とし穴が開いてしまいました。

`user_gacha_log`テーブルの★5獲得履歴が以下のような状態で、この3人のユーザーが処理上同時にガチャに参加し、同時に★5を当選させると……。

| user_id | rare | number | count |
|:--|--:|--:|--:|
| 1 | 5 | 2 | 2 |
| 2 | 5 | 1 | 2 |
| 2 | 5 | 2 | 2 |
| 3 | 5 | 1 | 2 |
| 3 | 5 | 2 | 1 |

本来はあと1つしか在庫がないはずの「サンタクロース」を、3人全員が獲得することになってしまいます。

3人のユーザーの処理が、それぞれ上記のログテーブルの状態で`getRareRateList()`、そして`getRareItemRateList()`をすると、3人に「★5は1つ残っている」「★5はサンタクロースが1つ残っている」という情報が返され、そのまま抽選・付与されてしまうからです。この処理の結果、サンタクロースは「残り-2個」という、もっともリアルでない値をとることになります。

## 在庫を管理するテーブルを作る

解決する方法の一つは、一人がガチャに参加している間、他の誰も、同時に参加することができないようにすることです。`user_gacha_log`をテーブル単位でロックしてしまえば、一人が「石を入れてから景品を獲得するまで」、他のユーザーがログを登録・更新する、即ち「景品を横取りする」ことを防ぐことができます。
しかし、多人数が同時に楽しむことをその価値としている「ソーシャル」ゲームにおいて、そのような順番待ちは推奨されるものではないでしょう。

そこで今回は、在庫を管理するテーブルを作ることにします。

- 全体の在庫管理テーブル（`common_gacha_rare_stock`）

| rare | stock_1 | stock_2 | stock_3 | stock_4 | stock_5 |
|:--|--:|--:|--:|--:|--:|
| 5 | 5 | 5 | 0 | 0 | 0 |
| 4 | 33 | 33 | 34 | 0 | 0 |
| 3 | 178 | 178 | 178 | 178 | 178 |

負の値を持たないよう、すべての値はUNSIGNED属性をつけます。
レアリティ毎に行を持つこのテーブルに対して、一つの抽選処理が終わる度に、獲得したものを渡して減算をかけます。
これによって行単位のロックがかかり、「参加は同時にできるが、景品の獲得は一人ずつ順番に」処理されるようになります。
先ほどの例を引けば、3人が残り1体のサンタクロースを当選させたとしても、最後の`reduceStock()`で★5の在庫行がロックされ、最初の一人は1→0の減算で正常に終了、それ以降のユーザーは0→-1の減算となり、負の値を持ち得ないのでエラーになる……よって、設定した在庫以上に景品が排出されることを水際で防ぐ、という寸法です。
このテーブルを用意したことによって、`user_gacha_log`から複数レコードを引っ張ってきてコード上で計算する必要もなくなったので、そこの処理も変えてしまいましょう。

```php:Gacha.php
/**
 * ガチャを回す
 * @param int $playCount 参加回数(1=単発,10=10連/default:1)
 * @return array 抽選結果を格納した配列
 **/
public function playGacha( $playCount = 1 )
{
	// $gachaIdの定義は省略

	$result = array();
	$getList = array();

	for( $i = 0; $i < $playCount; $i++ ) {

		// レアリティの確率テーブルを取得
		$rareRateList = self::getRareRateList( $gachaId );

		// レアリティを抽選
		$gachaRare = self::getRandom( $rareRateList );


		// 同レアリティのアイテムテーブルを取得
		$itemRateList = self::getRareItemRateList( $gachaId, $gachaRare->getRate() );

		// アイテムを抽選
		$item = self::getRandom( $itemRateList );
		$result[] = $item;


		// 在庫管理テーブルへの減算処理用
		if ( isset( $getList[$item->getRare()][$item->getNumber()] ) ) {
			$getList[$item->getRare()][$item->getNumber()] += 1;
		} else {
			$getList[$item->getRare()][$item->getNumber()] = 1;
		}
	}

	// 在庫を減らす
	foreach ( $getList as $rare => $list ) {
		Db_Sql_Common_Gacha_Rare_Stock::reduceStock( $gachaId, $rare, $list );
	}

	return $result;
}

/**
 * master_gacha_rare_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_rateのオブジェクトを格納した配列
 **/
public static function getRareRateList( $gachaId )
{
	$list = Db_Sql_Master_Gacha_Rare_Rate::selectList( $gachaId );

	// レアリティ毎に在庫を取得して、在庫が切れていたら配列から除く
	foreach( $list as $key => $data ) {

		// 在庫を取得
		$stock = Db_Sql_Common_Gacha_Rare_Stock::select( $gachaId, $rare );

		// 在庫を確率として扱う
		$data->setRate( $stock->getRareSum() );

		if ( $data->getRate() <= 0 ) {
			unset( $list[$key] );
		}
	}

	return $list;
}

/**
 * master_gacha_rare_item_rateのデータを取得
 * @param int $gachaId ガチャID
 * @return array master_gacha_rare_item_rateのオブジェクトを格納した配列
 **/
public static function getRareItemRateList( $gachaId, $rare )
{
	$list = Db_Sql_Master_Gacha_Rare_Item_Rate::selectList( $gachaId, $rare );

	// レアリティで在庫を取得して、在庫が切れていたら配列から除く
	foreach( $list as $key => $data ) {

		// 在庫を取得
		$stock = Db_Sql_Common_Gacha_Rare_Stock::select( $gachaId, $rare );

		$getMethod = 'getStock'.$data->getNumber();

		// 在庫を確率として扱う
		$data->setRate( $stock->{$getMethod}() );

		if ( $data->getRate() <= 0 ) {
			unset( $list[$key] );
		}
	}

	return $list;
}
```

```php:Db_Sql_Common_Gacha_Rare_Stock.php
/**
 * 特定のレアリティのレコードを1件取得
 *
 * @param int $gachaId ガチャID
 * @param int $rare レアリティ
 * @return Db_Data_Common_Gacha_Rare_Stock レコードオブジェクト
 */
public static function select($gachaId, $rare)
{
    $result = null;

    // $dbの定義は省略

    try {
        $table  = new Db_Dao_Common_Gacha_Rare_Stock($db);
        $select = $table->select()->from($table,
                '*, ('.Db_Dao_Common_Gacha_Rare_Stock::STOCK_1.' + '.
                Db_Dao_Common_Gacha_Rare_Stock::STOCK_2.' + '.
                Db_Dao_Common_Gacha_Rare_Stock::STOCK_3.' + '.
                Db_Dao_Common_Gacha_Rare_Stock::STOCK_4.' + '.
                Db_Dao_Common_Gacha_Rare_Stock::STOCK_5.') AS rareSum'
            )
            ->where(Db_Dao_Common_Gacha_Rare_Stock::GACHA_ID.' = ?', $gachaId)
            ->where(Db_Dao_Common_Gacha_Rare_Stock::RARE.' = ?', $rare);

        $row = $table->fetchRow($select);
        if (!is_null($row)) {
            $result = new Db_Data_Common_Gacha_Rare_Stock();
            $result->setRow($row);
        }

    } catch (Exception $e) {
        throw $e;
    }

    return $result;
}


/**
 * 在庫を減らす
 *
 * @param  int $gachaId ガチャID
 * @param  int $rare レアリティ
 * @param  array $getList 獲得リスト(=各orderの減らす数)
 * @return int 0=失敗; 更新された行数
 */
public static function reduceStock($gachaId, $rare, $getList)
{
    $result = 0;

    // $dbの定義は省略

    try {
        $table  = new Db_Dao_Common_Gacha_Rare_Stock($db);
        $updateRow = array();
        foreach( $getList as $rare => $count ) {
            $columnConst = constant( 'Db_Dao_Common_Gacha_Rare_Stock::STOCK_'.$rare );
            $updateRow[$columnConst] = new Zend_Db_Expr(
                $columnConst.' - '.$count
            );
        }
        $updateRow[Db_Dao_Common_Gacha_Rare_Stock::UPDATE_DATE] = new Zend_Db_Expr('NOW()');

        // アップデート実行
        $result = $table->update(
            $updateRow,
            array(
                // WHERE
                $table->getAdapter()->quoteInto(
                    Db_Dao_Common_Gacha_Rare_Stock::GACHA_ID.' = ?',
                    $gachaId
                ),
                $table->getAdapter()->quoteInto(
                    Db_Dao_Common_Gacha_Rare_Stock::RARE.' = ?',
                    $rare
                )
            )
        );

    } catch (Exception $e) {
        throw $e;
    }

    return $result;
}
```

これで、

- 抽選の母数が定まっており
- 一度にどれだけの人数が参加してもよい

ガチャを作ることができました。

# 終わりに

在庫管理テーブル`common_gacha_rare_stock`には、レアリティ単位で行を設ける都合上、決まった数のカラムしか用意することができませんでした。
なので、継続的な運用に際しては、レアリティごとの登録景品種類がカラムの数を上回らないように、設定段階での注意が必要になります。運用途中でカラムを増やす、とかでなければ、いくらでも増やしてよいと思いますが。

あとはまぁ、「目玉景品がなくなったら、在庫をリセットして継続したい」とか、「目玉景品の単価を維持するために是々こうしたい」とか、売上を視野に入れると色々な欲が出てくるわけですが……それはまた別のお話。


# 参考
[確率と統計、たまにガチャの話](https://qiita.com/massoumen/items/e84ded24a4f00860e215)
[MySQLでトランザクションの4つの分離レベルを試す - FAT47の底辺インフラ議事録](http://d.hatena.ne.jp/fat47/20140212/1392171784)

[^1]: 確率は千分率（‰）で記載します。また、それぞれのテーブルにはAUTO INCREMENTでPRIMARY KEYな`id`カラムと、今回のガチャであることを定義する`gacha_id`カラムがあるものとします。
[^2]: タグに記載の通り、PHP-ZendFrameworkとMySqlで作成する前提です。