前回の投稿「Javaの乱数生成器について速度面から調査してみる」でそれほど長周期が必要ないならThreadLocalRandom(SplitMix64)でいいかな、なんて書いたものの、そもそも「長周期が必要ない」もしくは「長周期が必要」ってどういうとき?ってなったのでガチャを例に考察してみます。
周期とは
ThreadLocalRandomに使われているSplitMix64などの疑似乱数には内部状態があり乱数を生成するごとに内部状態を更新していきます。
同じ内部状態から生成される乱数は同じになるので、内部状態が1周して同じ値に戻ってくるとそれ以降はまた同じ乱数の系列が出力されることになります。これが周期です。
ちなみにThreadLocalRandomの周期と内部状態はJava17のAPIドキュメントによると
周期(Period):$ 2^{64} $
内部状態(StateBits):64bit
であることがわかります。
ガチャに必要な情報量
ガチャは抽選方法によっていろいろ作りが違うので、ここでは簡単に計算できるように最小確率のアイテムが0.1%だとして、出現確立を1000分率で設定することとします。(全排出アイテムの総和が1000となるように設定されている状態)
この状態は0から999の1000個の数字がそれぞれ出現アイテムに紐づいていると考えられます。
例えば0.1%のものなら一つの数字に紐づけ、10%のものなら100個の数字に紐づけます。(総和が1000なのですべてのアイテムは少なくとも1つ以上の数字と紐づいた状態になっています)
1連ガチャであれば1000個の数字がまんべんなく出てくれれば、想定通りの確率で排出されそうです。
1000をビット数であらわすと$ log_2{1000} \fallingdotseq 9.97 $なので内部状態が64ビットあるThreadLocalRandomでも大丈夫そうです。
では10連ガチャの場合どうでしょう。10連なので情報量は10倍で $ 9.97 \times 10 = 99.7 $ ほぼ100ビット必要ということになります。(ここでは最終的に排出されるアイテムの組み合わせではなく、すべてのパターンが出現する可能性があるかで考えています)
これではThreadLocalRandomでは1周してもすべてのパターンを網羅することはできません。(つまり結果が偏る可能性がある)
まとめ
単発でしか利用されない抽選であれば通常の疑似乱数でも十分と言えそうですが、
簡略化したガチャのモデルでも10連となるとThreadLocalRandomの$2^{64}$周期では足りないことがわかりました。
実際のガチャでは小数点第5位ぐらいまで表記してあるものもあり、その場合前述のアルゴリズムだと1000万分率で設定することになるので情報量は増えます。(10連で約232bit)
結局運営によってどう設定されるかわからないガチャの設定に対して正しく輩出させるにはCSPRNG(暗号論的疑似乱数。JavaならばSecureRandom)を使っておくのが安心な気がしてきました。速度の面はガチャに限って言えば排出アイテムをデータベースに書き込む処理時間が支配的になることが多いので、速いに越したことはないにせよ問題になることは少ないと思います。
PRNGの話してたのに結局CSPRNGに行きついてしまって微妙ですが、ガチャ排出確立問題は非常にシビアなので・・・
おまけ
この記事ではガチャでの話になっていますがランダム文字列の生成でも同じようなことが言えます。
大小英字+数字の62種類の文字からなる30文字のランダム文字列を作ることを考えると
$log_2{62} \fallingdotseq 5.95(bit)$
$5.95 \times 30 = 178.5 (bit)$
となるのでそれなりに周期の長い乱数でないとまずそうです。
ただ、この手の文字列は一時キーに使ったりするシチュエーションが多いと思うので、セキュリティを考えるとそもそもCSPRNGで生成すべきと言えるでしょう。