遊戯王のプログラマブルな初期手札確率計算機を作りたい

遊戯王というトレーディングカードゲームがあります.
遊戯王では同名カード上限 3 枚で構成される 40-60 枚のデッキ(山札)からゲーム開始時にお互いが 5 枚のカードを引くのですが,1-2 回のターンの往復でゲームが終了することも珍しくない遊戯王というカードゲームにおいてはこれらの初手の中に必要なカードの組み合わせが存在するかどうかが勝敗に直結する重要な要素になります.

たとえば「デッキに 3 枚存在するカードを 5 枚の手札の中に引き込む確率の計算」であれば初等的な計算であり,計算を行えるサイトもいくつか存在します.
一方,デッキによっては「グループ A に属するカードとグループ B に属するカードを組み合わせて引き込む」「カード A をを引きつつカード B は引かない」「カード A が一枚はデッキ内にある」といった複雑な条件で初動が成立するかどうかが決まる場合もあります.
最たる例として《スモール・ワールド》というカードがあり,これは手札の別のモンスター・カードと併せることで非常に複雑な条件に合致したカードをデッキから手札に加える効果を持ちます.シンプルな確率計算機・シミュレータでは《スモール・ワールド》を考慮した初動確率を計算することは非常に難しい,またはユーザの負荷が大きすぎるといえます.

競技シーンで遊戯王をプレイされている方々にとって,プレイアウトを重ねるよりも手っ取り早く正確に初期手札の確率を計算する手段が存在することには少なからぬ価値があるように思います.
そこでより複雑な条件設定の下で確率を計算できる「プログラマブルな」ツールを作ることができれば面白そうだと思い立ったという感じになります.

とりあえずまずはライブラリという形で提供していければそこからできることはかなり広がりそうです.
以前 F# で遊戯王カードのスタッツを表現するライブラリを個人的に作成していたことを思い出したので,それをもとに簡単な設定での確率計算機を作ってみることにしました.

とりあえずまずは愚直なコーディングで,普段私がマスターデュエルで使っている斬機コードトーカーの先攻初動確率(超階乗を構える基本展開のできる手札である確率)と初手誘発枚数期待値(名称ターン一重複は考慮せず)をシミュレートしてみました.
open YuGiOh
module Cards =
open Monster
open Spell
open Trap
let ``ドロール & ロックバード`` =
Card.モンスターカード ("ドロール & ロックバード", "ドロール アンド ロックバード", 風属性, 魔法使い族, 1, 0, 0)
let ``ドットスケーパー`` = Card.モンスターカード ("ドットスケーパー", "ドットスケーパー", 地属性, サイバース族, 1, 0, 2100)
let ``マイクロ・コーダー`` = Card.モンスターカード ("マイクロ・コーダー", "マイクロ コーダー", 闇属性, サイバース族, 1, 300, 0)
// 中略
let ``ファイアウォール・ドラゴン・ダークフルード - ネオテンペスト`` =
Card.Lモンスターカード(
"ファイアウォール・ドラゴン・ダークフルード - ネオテンペスト",
"ファイアウォール ドラゴン ダークフルード ネオテンペスト",
闇属性,
サイバース族,
[ U; L; R; DL; DR ],
3000
)
open Cards
let recipe: DeckRecipe =
{ Main =
[ ``ドロール & ロックバード``, 3
``ドットスケーパー``, 1
``マイクロ・コーダー``, 3
``増殖するG``, 3
``幽鬼うさぎ``, 1
``灰流うらら``, 3
``ファイアウォール・ガーディアン``, 1
``斬機シグマ``, 1
``斬機アディオン``, 1
``斬機サブトラ``, 1
``斬機マルチプライヤー``, 1
``斬機ダイア``, 1
``斬機サーキュラー``, 3
``ファイアウォール・ディフェンサー``, 3
``パラレルエクシード``, 3
``原始生命態ニビル``, 2
``サイバネット・マイニング``, 2
``斬機方程式``, 1
``墓穴の指名者``, 2
``抹殺の指名者``, 1
``無限泡影``, 1
``斬機超階乗``, 1
``サイバネット・コンフリクト``, 1 ]
Extra =
[ ``サイバース・ディセーブルム``, 1
``塊斬機ラプラシアン``, 1
``塊斬機ダランベルシアン``, 1
``転生炎獣アルミラージ``, 1
``リングリボー``, 1
``リンク・デコーダー``, 1
``コード・トーカー``, 1
``スプラッシュ・メイジ``, 2
``ピットナイト・アーリィ``, 1
``トランスコード・トーカー``, 2
``デコード・トーカー・ヒートソウル``, 1
``アクセスコード・トーカー``, 1
``ファイアウォール・ドラゴン・ダークフルード - ネオテンペスト``, 1 ]
Side = [] }
assert (recipe.MainCount = 40)
assert (recipe.ExtraCount = 15)
let random = System.Random 0xbeef
let 初動手札 (hand: ICard list) : bool =
let サーキュラー初動札 card : bool =
[ ``斬機サーキュラー`` :> ICard; ``サイバネット・マイニング`` ] |> List.contains card
let 通常召喚可能 (card: ICard) : bool =
match card with
| (:? Monster.Card as card) when card.LevelPlace <= Level 4 -> true
| _ -> false
let 自力特殊召喚可能斬機 (card: ICard) : bool =
match card with
| (:? Monster.Card as card) when card = ``斬機シグマ`` || card = ``斬機サブトラ`` || card = ``斬機シグマ`` -> true
| _ -> false
let 斬機初動 (hand: ICard list) : bool =
([ 0..4 ], [ 0..4 ])
||> Seq.allPairs
|> Seq.exists (fun (i, j) ->
自力特殊召喚可能斬機 hand[i]
&& hand[j] :? Monster.Card
&& hand[j] :?> Monster.Card |> _.LevelPlace = Level 4)
let パラエク初動 (hand: ICard list) : bool =
let パラエク = hand |> List.exists ((=) ``パラレルエクシード``)
let 通常召喚可能 = hand |> List.exists 通常召喚可能
let notパラエク素引き = hand |> List.filter ((=) ``パラレルエクシード``) |> List.length < 3
パラエク && 通常召喚可能 && notパラエク素引き
hand |> List.exists サーキュラー初動札 || 斬機初動 hand || パラエク初動 hand
let 誘発札: ICard list =
[ ``ドロール & ロックバード``; ``増殖するG``; ``幽鬼うさぎ``; ``灰流うらら``; ``原始生命態ニビル``; ``無限泡影`` ]
let mutable 初動回数 = 0
let mutable 誘発合計 = 0
let numTrial = 1000000
for trial in 1..numTrial do
let handIndicesDup = [ for i in 1..5 -> random.Next(recipe.MainCount - i) ]
let mutable handIndices = []
for h in handIndicesDup do
let mutable h = h
while handIndices |> List.contains h do
h <- h + 1
handIndices <- h :: handIndices
let mutable hand = []
for h in handIndices do
let mutable j = 0
let mutable total = 0
while h >= total do
total <- total + snd recipe.Main.[j]
j <- j + 1
j <- j - 1
hand <- fst recipe.Main.[j] :: hand
if trial % 100000 = 0 then
printfn "Trial: %d" trial
printfn "%s" (hand |> List.map _.NameString |> List.reduce (fun a b -> a + " | " + b))
if 初動手札 hand then
初動回数 <- 初動回数 + 1
誘発合計 <- 誘発合計 + (hand |> List.filter (fun h -> 誘発札 |> List.contains h) |> List.length)
printfn "初動率: %f %%" (float 初動回数 / float numTrial * 100.0)
printfn "誘発期待値: %f 枚" (float 誘発合計 / float numTrial)
初動率: 80.605300 %
誘発期待値: 1.669609 枚
良い感じの数値になっていると思います(初動率が低いのは気にしないでください).
初動率は《斬機超階乗》の素引きや「《ディフェンサー》→《ヒートソウル》→1枚ドロー→斬機自己 SS→ダランベルシアン」といったルートは考慮していないので実際はもう少し高いです.

カテゴリ名や手札での組み合わせの指定,デッキに存在するかどうかなどの条件指定を上手くユーティリティ関数に落とし込めればかなり使える感じにはなりそうな気がしています.

(F# で作っても誰も使わないという根本的な問題があるのは言わない約束)

最終的にはこういった感じのライブラリを基にして利便性の高い GUI ツールにまで持っていければ大成功という感じですかね.

アイデアがある方がいらっしゃれば是非コメントいただけるとありがたいです.

当座の目標は《スモール・ワールド》込みの計算の実装です.

これがうまくいけば,与えられた初動パターンから強化学習で最適な採用バランスを提案するみたいなこともできるかもしれないですね.

DSL を良い感じにして書きやすくしてみました.
あと上記コードのランダム 5 枚ドローがあまりにいい加減で確率分布に偏りがあることが判明したのでその辺も改めて書き直しました.
let 初動手札 (state: State) : bool =
let is自力特殊召喚可能斬機 = [ "斬機アディオン"; "斬機サブトラ"; "斬機シグマ" ] |> Card.isNameIn
let isパラエク = Card.isNameOf "パラレルエクシード"
let is通常召喚可能 = Card.isLevelIn 1 4
let isサーキュラー初動 = state.CondInHand(Card.isNameIn [ "斬機サーキュラー"; "サイバネット・マイニング" ])
let is斬機初動 = state.CondInHand2 is自力特殊召喚可能斬機 (Card.isLevel 4)
let isパラエク初動 = state.CondInHand2 isパラエク is通常召喚可能 && state.CondInDeck isパラエク
isサーキュラー初動 || is斬機初動 || isパラエク初動
let is誘発札 =
[ "ドロール & ロックバード"; "増殖するG"; "幽鬼うさぎ"; "灰流うらら"; "原始生命態ニビル"; "無限泡影" ]
|> Card.isNameIn
let mutable 初動回数 = 0
let 誘発枚数回数 = [| for _ in 0..6 -> 0 |]
let random = System.Random 0xbeef
let 試行回数 = 1000000
for trial in 1..試行回数 do
let state = State(recipe, random)
if trial % 100000 = 0 then
printfn "Trial: %d" trial
printfn "%s" (state.Hand |> List.map _.Name |> List.reduce (fun a b -> a + " | " + b))
if 初動手札 state then
初動回数 <- 初動回数 + 1
let 誘発枚数 = state.CountCondInHand is誘発札
誘発枚数回数.[誘発枚数] <- 誘発枚数回数.[誘発枚数] + 1
let 誘発合計 = [ 0..6 ] |> Seq.sumBy (fun i -> i * 誘発枚数回数.[i])
let 誘発期待値 = float 誘発合計 / float 試行回数
let 誘発標準偏差 =
([ 0..6 ] |> Seq.sumBy (fun i -> (float i - 誘発期待値) ** 2.0 * float 誘発枚数回数.[i]))
/ float (試行回数 - 1)
|> sqrt
printfn "初動率: %f %%" (float 初動回数 / float 試行回数 * 100.0)
printfn "誘発期待値: %f ± %f 枚" 誘発期待値 誘発標準偏差
初動率: 75.510300 %
誘発期待値: 1.625677 ± 0.991613 枚

State
がデッキと手札の情報を保持していて,手札やデッキに所望の組み合わせがあるかどうかを判定する API を提供します.

state.CondInHandN
で手札に所定の条件を満たす N
枚組が存在するかどうかをチェックできる感じです.
たとえば手札に《パラレルエクシード》とレベル4以下のモンスター一体(このデッキでは全員がリンク1に繋がるので初動になります),デッキに別の《パラレルエクシード》一枚という組み合わせを指定する場合
let isパラエク = Card.isNameOf "パラレルエクシード"
let is通常召喚可能 = Card.isLevelIn 1 4
let isパラエク初動 = state.CondInHand2 isパラエク is通常召喚可能 && state.CondInDeck isパラエク
と書けます.
だいぶ直感的な指定になったのではないでしょうか.

作りかけも甚だしいですが一応 GitHub にアップしました.