ようこそ。睡眠不足なプログラマのチラ裏です。

F#で簡素なモゲマスコンプガチャシミュレータ

椎名林檎自由へ道連れ」をヘビロテしすぎて脳内無限ループしている今日この頃ですが、皆様いかがお過ごしでしょうか。
時事ネタとしては旬を逃した感じですが、簡素なコンプガチャシミュレータをF#で書いてみました。



とは言っても、この記事で伝えたいことはコンプガチャの確率がどうのですとか、実社会におけるコンプガチャの問題点がどうのとかいうような話題を扱うものではなく、安直にモナド則を満たさないコンピューテーション式を作ってしまうよりかは、Identityモナド(恒等モナド)を使ってプログラミングをした方が、見通しが良くモジュール性の高いコードを書くことができるかもしれないよ、という話題を提供します。割とどうでもいい話題ですね。未だガラケーユーザーであり、スマホやソーシャルゲーとはほとんど縁のない私ですが(もちろんモゲマスもやったことない)、気が向いたのでちょっと書いてみました。なお、モゲマスおよびFSharpxのステマではありません。


シミュレートするコンプガチャの仕様

まずはシミュレートするコンプガチャの仕様について簡単に確認しておきましょう。今回実装してみるのは、モゲマスことモバゲー『アイドルマスター シンデレラガールズ』のコンプガチャ「パジャマパーティー」に近いコンプガチャのシミュレートを目的としてみます。




Google先生に聞いてきた モゲマス 「パジャマパーティー」コンプガチャの概要

時折行われる課金ガチャのイベント企画。1ガチャあたり300円を支払って利用するガチャです。1回に1枚のアイドルカードが得られます。実際の支払いは「モバコイン」ですが、モバコインは100G=100円で購入して利用するため、便宜上こちらではそのまま円とします。イベント期間中以下の「レア」パジャマアイドルが確率テーブルに5枚追加され、その5枚を全て集めると、特典として限定「Sレア」カード「[眠れる姫君]星井美希」を獲得できるというもの。


コンプ対象パジャマアイドル
・[パジャマパーティー]緒方智絵里 コスト12 攻2880 守1600 キュート攻中アップ
・[パジャマパーティー]間中美里 コスト08 攻1240 守1440
・[パジャマパーティー]黒川千秋 コスト10 攻1860 守1560 クール攻中アップ
・[パジャマパーティー]川島瑞樹 コスト09 攻1400 守1680
・[パジャマパーティー]若林智香 コスト12 攻1600 守2680 パッション守中アップ



なお、バンナムによる公式の発表はないが、いずれかのパジャマアイドルが出現する確率は12%程度とのこと。
  
※ガチャのセット販売も行われているが、ここでは1回ずつガチャを行うこととする。
※なお、ネットで適当に拾ってきた情報のため正確ではない可能性あり。

内部の実装の詳細はわかりませんが、「今すぐモゲマスPすべてにSレアを授けてみせろッ! ver. 0.141.33」というシミュレータが既にあるようです。
http://mugenmasakazu.plala.jp/cgi-bin/nijiuraIDOLMASTER/mogemaskillyourself.cgi



なお、コンプガチャがどうして危険と言われているのかの理由については、「コンプガチャの確率マジックを中学生にも分かるように説明するよ - てっく煮ブログ」の解説がわかりやすい。わたしが中学生にも分かるように説明するなら、例えばモンハンの素材であるところの「逆鱗(出現率2%)」が5種類あったとして、それらすべてを揃えないと作れない武器があったとき、それにかかる時間を想像してみると、コンプガチャへ挑む無謀さが割と想像しやすい、とか。欲しいと思う素材ほど出ないようになっているといわれる架空のシステム。いわゆる「センサー」の存在への疑い、とか。



簡素なコンプガチャシミュレータを愚直に書いてみる

まずは愚直に。細かいことは考えずにとりあえず実装してみたバージョン。

open System

let tee x f = f x; x
let (|>!) x f= tee x f

let rand = new Random(DateTime.Now.Millisecond);

type Rarity = 
  |R of string 
  |Other

// ガチャアイテム
let a,b,c,d,e,other = R("緒方智絵里"), R("間中美里"), R("黒川千秋"), R("川島瑞樹"), R("若林智香"), Other

// コンプ
let comp = [a;b;c;d;e]

// コンプ対象が出る確率
let probability = 0.12

let shuffle source =
  let array = List.toArray source
  let rec loop i =
    i |> function
    | 1 -> ()
    | _ ->
      let i =  i - 1
      let j = rand.Next(i)
      let temp = array.[i]
      array.[i] <- array.[j]
      array.[j] <- temp;
      loop i
  loop source.Length
  [for x in array do yield x]

let completeGacha lst count total =
  let items = 
    let dummy p = 
      let e = ((float comp.Length) / p) |> int
      [for i in 1..(e-comp.Length)  do yield Other]
    let target = comp@dummy probability 
    target |> shuffle

  let gacha () = rand.Next(1, items.Length) |> fun i -> items.[i]

  let rec gacha' count total =
    let newitem = gacha ()
    let current = count + 1
    if List.exists (fun x -> x = newitem) comp |> not then
      (* でねぇ!!!*)
      gacha' current total
    elif List.forall (fun x -> x = newitem |> not) lst |> not then
      (* ダブりかよ...orz *)
      gacha' current total
    else
      (* よっしゃー!なんという引きの良さ!!! *)
      lst@[newitem], current, (total + current), List.length (lst@[newitem]) = comp.Length
  gacha' count total

let printGacha x = 
  x |>! (fun (possession, n, total, complete) -> 
          let g = sprintf "%d回:%d円 " n (300 * n)
          let sum = sprintf "合計%d円" (300 * total)
          let result = sprintf "%s" (if complete then "コンプ" else "未コンプ")
          printfn "%s %s %A %s" g sum possession result)
  
let cut (a,b,c,d) = a,b,c

completeGacha [] 0 0 |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|> fun _ -> printfn "[眠れる姫君]星井美希を手に入れた!" 

Console.ReadLine () |> ignore


ジェネリックもへったくれもない。いくら適当とはいえ愚直すぎて泣ける。



結果は当然実行ごとに毎回変わりますが、一応実行結果の例。

1回:300円  合計300円 [R "黒川千秋"] 未コンプ
8回:2400円  合計2700円 [R "黒川千秋"; R "緒方智絵里"] 未コンプ
38回:11400円  合計14100円 [R "黒川千秋"; R "緒方智絵里"; R "川島瑞樹"] 未コンプ
78回:23400円  合計37500円 [R "黒川千秋"; R "緒方智絵里"; R "川島瑞樹"; R "間中美里"] 未コンプ
108回:32400円  合計69900円 [R "黒川千秋"; R "緒方智絵里"; R "川島瑞樹"; R "間中美里"; R "若林智香"] コンプ
[眠れる姫君]星井美希を手に入れた!

1回目でレアカード"黒川千秋"を引き当てるという強運を発揮するも、2枚目のレア"緒方智絵里"を引き当てるには8回かかる。
そして、あらあらまあまあ最終的には合計69900円のぶっこみ。バンナムにかなり貢ぎましたな。



簡素なコンプガチャシミュレータをコンピューテーション式で

愚直にもほどがあるので、もうちょっとなんとかしてみましょう。

completeGacha [] 0 0 |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|||> completeGacha |> printGacha |> cut
|> fun _ -> printfn "[眠れる姫君]星井美希を手に入れた!" 

上記部分に着目すると、なんだか順々に関数を適用していく流れが見えます。なんだかコンピューテーション式にできそうです。
ということで、とりあえずコンピューテーション式にしてみる。

namespace Library1

[<AutoOpen>]
module CompleteGacha =
  open System

  let tee x f = f x; x
  let inline (|>!) x f= tee x f

  let rand = new Random(DateTime.Now.Millisecond);
  let shuffle source =
    let array = List.toArray source
    let rec loop i =
      i |> function
      | 1 -> ()
      | _ ->
        let i =  i - 1
        let j = rand.Next(i)
        let temp = array.[i]
        array.[i] <- array.[j]
        array.[j] <- temp;
        loop i
    loop (List.length source)
    [for x in array do yield x]

  let completeGacha comp d probability (lst:'a list) count total =
    let items = 
      let dummy p = 
        let e = ((float <| List.length comp) / p) |> int
        [for i in 1..(e - (List.length comp)) do yield d]
      let target = comp@(dummy probability)
      target |> shuffle

    let gacha () = 
      let i = rand.Next(1, (List.length items)) 
      items.[i]

    let rec gacha' count total =
      let newitem = gacha ()
      let current = count + 1
      if List.exists (fun x -> x = newitem) comp |> not then
        (* でねぇ!!! *)
        gacha' current total
      elif List.forall (fun x -> x = newitem |> not) lst |> not then
        (* ダブりかよ...orz *)
        gacha' current total
      else
        (* よっしゃー!なんという引きの良さ!!! *)
        lst@[newitem], current, (total + current), List.length (lst@[newitem]) = List.length comp
    gacha' count total

  type CompGacha<'a> = CompGacha of 'a 

  type CompGachaBuilder () =
    member this.Bind(m, f) : CompGacha<_> = 
      let (CompGacha (comp, d, p, lst,count,total,complete)) = m
      let lst,count,total,complete = completeGacha comp d p lst count total 
      f (comp,d, p, lst,count,total,complete)
    member this.Return x = CompGacha(x)
    member this.ReturnFrom x = x

  let cg = new CompGachaBuilder()

  let printGacha price unit f x = 
    x |>! (fun (comp, d, p, possession, n, total, complete) -> 
            let g = sprintf "%d回:%d%s" n (price * n) unit
            let sum = sprintf "合計%d%s" (price * total) unit
            let result = sprintf "%s" (if complete then "コンプ" else "未コンプ")
            printfn "%s %s %A %s" g sum possession result
            if List.length comp = List.length possession then 
              f())

  open FSharpx
  open Operators
  let inline returnM x = returnM cg x 
  let inline (>>=) m f = bindM cg m f
  let inline (=<<) f m = bindM cg m f
  let inline ap m f = f <*> m
  let inline map f m = liftM cg f m
  let inline (<!>) f m = map f m
  let inline lift2 f a b = returnM f <*> a <*> b
  let inline (>>.) m f = bindM cg m (fun _ -> f)
  let inline (>=>) f g = fun x -> f x >>= g
  let inline (<=<) x = flip (>=>) x

利用側

open System
open Library1

type Rarity = 
  |R of string 
  |Other

// ガチャアイテム
let a,b,c,d,e,other = R("緒方智絵里"), R("間中美里"), R("黒川千秋"), R("川島瑞樹"), R("若林智香"), Other

// コンプ
let comp = [a;b;c;d;e]

// コンプ対象アイテムが出る確率
let probability = 0.12 // 12%

// 1ガチャあたり300let printg = printGacha 300 "円" (fun () -> printfn "[眠れる姫君]星井美希を手に入れた!") 

let mogemasu x = 
  cg { return x } 
  >>= fun x -> cg { return x |> printg } 
  >>= fun x -> cg { return x |> printg } 
  >>= fun x -> cg { return x |> printg } 
  >>= fun x -> cg { return x |> printg } 
  >>= fun x -> cg { return x |> printg } 

// 別の書き方
//let mogemasu x = 
//  cg { let! x = cg { return x } 
//       let! x = cg { return x |> printg } 
//       let! x = cg { return x |> printg } 
//       let! x = cg { return x |> printg } 
//       let! x = cg { return x |> printg } 
//       return x |> printg }

(comp, other, probability, [], 0, 0, false) |> mogemasu |> ignore
Console.ReadLine () |> ignore

とりあえずコンピューテーション式にしました以外の何物でもない。愚直版に比べるとそこそこ抽象化こそされているが、まだ不十分。コンプ対象カード中何枚揃えるまでガチャを行うかの部分がハードコーディングされている(この場合は5回のBindをすることで5枚揃えるまでガチャしている)。ちなみに、このコンピューテーション式は「Functor且つApplicative且つモナド」を満たさない。「コンピューテーション式がモナドである必要は必ずしもない」が、このような実装ではモジュール性の低下は否めない。



簡素なコンプガチャシミュレータをIdentityモナド

HaskellでIdentityモナド(恒等モナド)と言えば、一般的にはモナド変換子からモナドを導出するために使われることで知られている。内部処理を伴わない単なる関数適用をモナドで表現する目的でIdentityモナドを使うことは、Haskellではあまりしないのかもしれない。しかし、まったく意味がないというわけではない。モナドを利用することで、モジュール性が高まりプログラムの見通しが良くなる。「Functor且つApplicative且つモナド」ではないコンピューテーション式をわざわざ作るよりかは、Identityモナドを使った実装の方が見通しの良いプログラムが書けるかもしれない。



ではやってみよう。
FSharpxには標準で実装されていないため、まずはIdentityモナドをつくる必要がある。

module Identity =
  type M<'T> = M of 'T 
  let mreturn x : M<'T> = M x

  type IdentityBuilder () =
    member this.Return (x) = mreturn x
    member this.Bind ((M x),f) : M<'U> = f x

  let identity = IdentityBuilder ()

  open FSharpx
  open Operators
  let inline returnM x = returnM identity x 
  let inline (>>=) m f = bindM identity m f
  let inline (=<<) f m = bindM identity m f
  let inline (<*>) f m = applyM identity identity f m
  let inline ap m f = f <*> m
  let inline map f m = liftM identity f m
  let inline (<!>) f m = map f m
  let inline lift2 f a b = returnM f <*> a <*> b
  let inline ( *>) x y = lift2 (fun _ z -> z) x y
  let inline ( <*) x y = lift2 (fun z _ -> z) x y
  let inline (>>.) m f = bindM identity m (fun _ -> f)
  let inline (>=>) f g = fun x -> f x >>= g
  let inline (<=<) x = flip (>=>) x

利用側

open System
open Library1
open Library1.Identity

type Rarity = 
  |R of string 
  |Other

// ガチャアイテム
let a,b,c,d,e,other = R("緒方智絵里"), R("間中美里"), R("黒川千秋"), R("川島瑞樹"), R("若林智香"), Other

// コンプ
let comp = [a;b;c;d;e]

// コンプ対象アイテムが出る確率
let probability = 0.12 // 12%

// 1ガチャあたり300let printg = printGacha 300 "円" (fun () -> printfn "[眠れる姫君]星井美希を手に入れた!") 

let compGacha x = 
  identity { let comp,d,probability,lst,count,total,r  = x
             let lst,count,total,r = completeGacha comp d probability lst count total 
             return (comp,d,probability,lst,count,total,r ) |> printg }

let mogemasu () = 
  (comp, other, probability, [], 0, 0, false) |> fun x -> 
  compGacha x >>= compGacha >>= compGacha >>= compGacha >>= compGacha 

// 別の書き方
//let mogemasu () = 
//  (comp, other, probability, [], 0, 0, false) |> fun x -> 
//  identity { let! x = compGacha x 
//             let! x = compGacha x 
//             let! x = compGacha x 
//             let! x = compGacha x
//             let! x = compGacha x 
//             return x }

mogemasu () |> ignore
System.Console.ReadLine () |> ignore

Console.ReadLine () |> ignore


Identityモナドを用いて実装することにより、冗長なコンピューテーション式をわざわざ作らなくても、見通しがよいコードを書くことができた。しかも、これはモナドであるためモジュール性が高い。その証拠にモナド則3の結合則から「何枚揃えるまでガチャを行うか」についての抽象を導き出すことができる。



例えばこうだ。

open System
open Library1
open Library1.Identity

type Rarity = 
  |R of string 
  |Other

// ガチャアイテム
let a,b,c,d,e,other = R("緒方智絵里"), R("間中美里"), R("黒川千秋"), R("川島瑞樹"), R("若林智香"), Other

// コンプ
let comp = [a;b;c;d;e]

// コンプ対象アイテムが出る確率
let probability = 0.12 // 12%

// 1ガチャあたり300let printg = printGacha 300 "円" (fun () -> printfn "[眠れる姫君]星井美希を手に入れた!") 

let compGacha x = 
  identity { let comp,d,probability,lst,count,total,r  = x
             let lst,count,total,r = completeGacha comp d probability lst count total 
             return (comp,d,probability,lst,count,total,r ) |> printg }

let mogemasu n = 
  (comp, other, probability, [], 0, 0, false) |> fun x -> 
  let cg = [for i in 1..n-1 do yield compGacha]
  List.fold (fun m f -> m >>= f) (compGacha x) cg

mogemasu 5 |> ignore
System.Console.ReadLine () |> ignore

Console.ReadLine () |> ignore


List.foldで必要回数分のモナドを結合することで、mogemasu関数を汎化することができた。
なお、List.foldでモナドを結合している部分は、下記のようにList.foldBackに書き直しても同様に動作する。このことからもモナド則3を満たしていることが確認できる。

let mogemasu n = 
  (comp, other, probability, [], 0, 0, false) |> fun x -> 
  let cg = [for i in 1..n-1 do yield compGacha]
  List.foldBack (fun m f -> f >>= m) cg (compGacha x)


まとめ
そのまま適用しただけでは何もしてくれないので、一見使いどころがなさそうなIdentityモナド
しかし、使えないようでいて実は割と使えるかもしれない、という話題でした。





読者の中には記事の誘導によってうまいこと騙されている人もいるかもしれないけど、
いや...、つーかさ。それ再帰で書けばいんじゃね?(核心


        i l l            ヽ    ヽ\\
        ヾy ‐-~~~ ヽ、    \    ヽ ヽ
         ィ   ヽ~\    ヽ        ヽ `、
        /         ー-、      \     `、
        /   ヽヾヽ\ ヽ\  ヽ、          、
       // /  |\      ヽ、   ヽ ヽ  |   l`、
       / |  |   l , 、\\\\       \  |   l 丶
       | l   |.   、! \ \ ー '''' ヽ、ヽ     l  |  | `
.      |.l  |  r'} 、 \,,、  、__,,、-‐''`ヽ  | |  |  |
       l.l  |  ( {  `ー''丶   '''ー- '´  |/ヽ | | | ii  |
        l   |  ヽ;      |         |' i| l | | |  i
       ヽ  .l   `i.     i       ノ, / / ///  /      __________
         \. l   ヽ.    ヽ      /`" / // |~ヽ     /
          ヽ.    ヽ  _,,,,,,_     /r、 / /  |   |  <またつまらぬコードを書いてしまった。
           \ /llヽ  ‐-、`'   /1| ヽ / /|   |    \__________
            /  ||∧.      / | |  \-‐'   |   |
        _ ,、 -/l   ||{ ヽ,,,,,,,,,/  .| |   |ヽ、、 |   |
    _,、- ' ´    |.   ||{        | |   |ヽ、 ゛|   |、,,_

関連記事


FizzBuzz問題から学ぶモナド
http://d.hatena.ne.jp/zecl/20110711/p1



上記記事で利用しているモナドもIdentityモナド