つれづれ趣味雑記

オタクになりきれないオタク。軽度なアニメオタク+ゲーム好き。運動にはまりつつある

【エンドフィールド】5000万回のガチャシミュレーションで導く最適な引き方

なにかと複雑なエンドフィールドの限定ガチャ。今回はガチャ戦略を切り替えながらガチャを引くコードを記述。合計5000万回に及ぶガチャシミュレーション から最適な引き方を計算させてみました

思わぬ方法が良い結果になってちょっとビックリしています

注意点

  • テキトーに作った自作シミュレーターです
  • バグってる可能性は多いにあります
  • この結論どおりガチャを回しても責任はとれません。自己責任でお願いします
  • 最後にコードも乗せますので、ご自身で検証してください

  • ぶっちゃけ5キャラ10キャラ程度ではリアルラックが一番大事です


キッカケ

なにかと複雑なエンドフィールドのガチャ。

特に厄介なのが「80連星6天井」は引き継ぎアリですが、「120連限定確定天井」は引き継ぎナシということ。

このため個人的にこのゲームのガチャは、天井120連までためて、溜まったらお目当てが出るまで回す が最適と考えていました。


が、気になったのが キャラ切り替えのたびに60連回す という戦略。どこで見たのか思い出せないのですが、たしかに一理ある。

60回回せば次のキャラ用の10連チケットがもらえます。つまり キャラ切り替えのたびに10連分お得。これはもしかしたらお得かもしれない…


とはいえこんな面倒なの、計算で導くのはかなり難しい。少なくとも自分はムリ。
実際のガチャではさらに30連引くと10回無料で回せます。しかも天井カウントには関係ない10連という…

このクッソややこしいガチャシステムは、シミュレーターを作って実験したほうが早い

そう判断しシミュレーターを作りました

ver1.2のコンテンツをプレイしろと(笑)


仕様

そんなわけでシミュレーターを作ります。要件として

  • 基礎確率0.8%
  • 66連目から5%ずつ上昇
  • 80連目で星6確定。50%ですり抜け
    • こちらはガチャ期間もまたぐ
  • 120連で確定
    • ただしお目当ての限定が出てない場合に限る
    • 補足:星6確定枠で限定が出たら、この120連天井は消えます
    • 次のガチャ期間に引き継ぎなし
  • 30連目で無料10連。天井カウント対象外
  • 60連まわすと、次のガチャ期間で無料10連可能

ガチです(笑)。そこらのテキトーに10連回してキャラを出すだけのシミュレーターではないです

ただし計算をラクにするため、10連単位 で計算します。
実際のガチャでも1連ずつ回すなんて天井付近でも無い限りしないと思いますし。必ず10連ガチャにするほうが実態に近いと判断しました


変数

AI使って最適戦略を考えさせれるとカッコイイんですが、さすがに手間かかりすぎます

そこでここでは以下を変数として、数十パターンでシミュレーションを行います

  • 何連分回せるようになったらガチャを引きに行くか
    • 40/50/60/80/100/120
  • 最大何連回すか
    • 40/50/60/80/100/120
  • 限定が出たら撤退するかどうか
    • True or False
    • 上述の「必ず60連」は撤退しないと判断した

ガチャ回数

ただちょっと迷ったのが、キャラ一人の期間(=約3週間)でどの程度ガチャチケ(石含む)を入手できるか

ver1.1が70連だったらしい。ただver1.0はもっと多かったし、ver1.2も1キャラしかいないので「キャラ期間」でみるともっと多い。

そこで今回はちょっと控えめに見積もり、1キャラ期間で40回(=1verで80連)として進めます。

微課金勢だと55回ぐらいでしょうか


シミュレーション開始!

やることは決まったのでシミュレーションを回します!今回は各設定において、1万キャラ分回すことにしました。

結果、合計ガチャ回数5000万オーバー というアホな自体になりました。(といっても20秒もいらないけど)

トップ5の発表です!


1位

映えある1位は、「120連溜まったら引く、最大120回、限定でたら撤退」 というド王道パターンでした!
1万シーズンのうち、限定を引けたのは5300回、恒常3368回です。平均91回で限定を引けています。

思ったより素直で予想どおりな結果でした

30連目/60連でもらえる10連チケもカウントしているせいで、平均ガチャ数が一般的な期待値より多めにでている


2位

2位はほぼ一緒です。「100連溜まったら引く、最大120回、限定でたら撤退」でした。
最初のスタート回数がちょっと減っただけですね。面白みがなかったです。

3位・4位

そして3位が、「120連溜まったら引く、最大120回、限定でても撤退しない」。4位がコレの100連スタート版です

なんと撤退しなくてもそれなりに効率いいのか…次の天井が近づくせいでしょうか?よくわかりません

5位

ここにきてようやく、「60連毎回回す。限定出ても撤退しない」 が入ってきました
最適とはいい難いですが、意外なほど強い戦略なのは間違いないようです。

ただし今回の設定だと、シーズンごとのチケット量が足りず、毎回60連引けたわけではないです
このため本来やりたかったことがやれていません


微課金すると?

そこで微課金させて、キャラごとに50連回せる設定に変えてみました。こうすると「毎回60連」が可能になり、毎回10連ガチャチケがもらえます。

これだと結果が激変して

  • 1位:120連開始・120連終了・限定でたらおしまい(一緒)
  • 2位:40連開始・60連終了・限定でても続ける(!)
  • 3位:50連開始・60連終了・限定でても続ける
  • 4位:60連開始・60連終了・限定でても続ける
  • 5位:40連開始・40連終了・限定でても続ける

一気に60連戦略が強くなりました

まじか…月パスだけでは足りないかもしれませんが、ある程度の課金が出来るならかなり効率的と言えそうです。


そしてこの方法のメリットは、毎回10連分多めに回せるから、武器ガチャを回しやすくなること。地味ですがこのゲームの武器ガチャは鬼畜仕様なので無視できません。

武器ガチャを回すのにキャラガチャが必要という鬼っぷり。ある意味ラクではあるのだけど


▲毎回わずかとはいえ、武器ガチャの資源が手に入るのは大きい


おわりに:面白すぎる結果だった

そんなわけで検証してみました。最初に述べた通りあっている保証はありません。

この通り回した結果については責任とれませんし、なにより 1万キャラ分=数百年分という人間一人では到底できないガチャ回数での平均結果です。
個人でのたった数キャラ分でのガチャでは「運」によるブレのほうがよっぽど大きいです


とはいうものの、「毎回60回」がかなり有効そう。武器ガチャも含めると本当に「アリ」に思えてきました。

問題は毎回60回引けるかどうかでしょうか…月パス+シーズンパスでは厳しい気がしており、さらに10連チケぐらい買う必要があるのではないかという気がしています。


最後に検証に用いたコードを貼ります。さすがにこんなのをgithubにあげる勇気はありません(笑)

気になる方はぜひご自身で作成してみてください。

それでは!

本当にver1.2を放置して何やってるんだか…鳴潮予告もみなきゃ…

帰宅途中に思いついて、コードを書き始めたら火が付いてしまい戻れなくなった


ソースコード:クリックで展開

from typing import Tuple
from dataclasses import dataclass
import random

RESULT_NONE = 0 # 引けなかった
RESULT_NO_LIMITED = 1 #恒常キャラ
RESULT_LIMITED = 2 #限定キャラ

@dataclass
class Result:
    limited_count: int
    no_limited_count: int
    start_req: int
    stop_flag: bool
    max_pulls: int
    total_gacha_count: int

class GachaSystem:
    # シーズン(=1キャラ)ごとのチケット入手数
    TICKET_OBTAIN_COUNT_PER_SEASON = 50
    
    def __init__(self):
        self.season_count = 0
        self.ticket_count = 0 # ガチャチケ数
        self.roll_count = 0  # 80連天井までのカウント
        self.roll_count_in_season = 0 # このシーズン中のガチャ数
        self.free_roll_count = 0 # 30連目で配布される無料ガチャ回数
        self.free_available = True # 無料10連をもらったことがあるかどうか
        self.ticket_from_prev_season = 0 #前シーズンで60回回したときにもらえる10連チケット
        self.has_120 = True # 120連天井があるか
        self.total_pulled = 0 # 実際に引いた単発回数

    # 次のシーズン(=ガチャ期間)に進む
    def next_season(self):
        self.season_count += 1
        self.ticket_count += GachaSystem.TICKET_OBTAIN_COUNT_PER_SEASON
        # 前シーズンで60回以上回したなら10連チケが手に入る
        # ただし1シーズン限定。
        if self.roll_count_in_season >= 60:
            self.ticket_from_prev_season += 10
        else:
            self.ticket_from_prev_season = 0
        self.roll_count_in_season = 0
        self.free_roll_count = 0
        self.free_available = True
        self.has_120 = True
    
    # 10回回す
    def roll10(self) -> int:
        result = RESULT_NONE
        for _ in range(0, 10):
            # 2枚引きは考慮しない
            # また10連で恒常と限定を両方引けたときは限定として返す
            r = self.roll()
            result = max(r, result)
        return result
        
    # 1回回す
    def roll(self) -> int: 
        total_count = self.ticket_count + self.ticket_from_prev_season + self.free_roll_count
        if total_count <= 0:
            # チケットがなかったら終了
            return RESULT_NONE
        # 実際に1回引く
        self.total_pulled += 1
        # 30連目でもらえる10連チケのときは天井カウントをすすめない。ガチャチケも減らさない
        if self.free_roll_count > 0:
            self.free_roll_count -= 1
        else:
            self.roll_count += 1
            self.roll_count_in_season += 1
            if self.ticket_from_prev_season > 0:
                self.ticket_from_prev_season -= 1
            else:
                self.ticket_count -= 1
            
        result = RESULT_NONE
        if self.roll_count_in_season >= 120 and self.has_120:
            result = RESULT_LIMITED
            self.roll_count = 0 # 120連で確定のため天井リセット
        else:
            if random.uniform(0, 100) <= self.percent():
                self.roll_count = 0 # 天井リセット
                if random.uniform(0, 100) <= 50:
                    result = RESULT_LIMITED
                else:
                    result = RESULT_NO_LIMITED
        # 30連目で無料10回配布
        if self.roll_count_in_season == 30 and self.free_available:
            self.free_roll_count = 10
            self.free_available = False
        # 限定がでたら120連確定が消える
        if result == RESULT_LIMITED:
            self.has_120 = False
        return result
    
    # 星6の確率。%で0.0から100.0。0.0〜1.0ではない
    def percent(self):
        # 65連目までは0.8%
        if self.roll_count <= 65:
            return 0.8
        # 80連で確定
        if self.roll_count >= 80:
            return 100
        # 66連から5%ずつ上昇
        return (self.roll_count - 65) * 5 + 0.8

    def total_tickets(self) -> int:
        return self.ticket_count + self.ticket_from_prev_season + self.free_roll_count
    
# N回シーズンを回してシミュレートさせる
# start_count: これだけのチケットがあったらガチャを回す
# stop_when_limited: 限定が出たらやめる
# max_pulls: 最大試行回数
def simulate(start_count: int, stop_when_limited: bool, max_pulls: int) -> Result:
    gacha = GachaSystem()
    limited_count = 0
    no_limited_count = 0
    for i in range(0, 10000):
        # 1シーズン進行
        gacha.next_season()
        # 一定枚数以上のガチャチケがあったら参戦
        if gacha.total_tickets() >= start_count:
            limited_appeared = False
            # print("------------------")
            while gacha.roll_count_in_season < max_pulls and gacha.total_tickets() > 0:
                r = gacha.roll10()
                if r == RESULT_LIMITED:
                    limited_count += 1
                    limited_appeared = True
                    if stop_when_limited:
                        break
                elif r == RESULT_NO_LIMITED:
                    no_limited_count += 1
            # 固定回数引く場合でかつガチャチケが余っている場合、さらに出るまで回す
            # こうしないとチケットが大量に余る可能性があり平等でない
            if not limited_appeared and gacha.total_tickets() >= 120 + max_pulls:
                while gacha.roll_count_in_season < 120 and gacha.total_tickets() > 0:
                    r = gacha.roll10()
                    if r == RESULT_LIMITED:
                        limited_count += 1
                        break
                    elif r == RESULT_NO_LIMITED:
                        no_limited_count += 1
        # 参戦しないシーズンでも、前シーズンから得た『期限付き10連チケット』があれば失効を防ぐため10連だけ回す
        # これにより、無償で将来のための天井カウントを稼ぐことができる
        elif gacha.ticket_from_prev_season > 0:
            r = gacha.roll10()
            if r == RESULT_LIMITED:
                limited_count += 1
            elif r == RESULT_NO_LIMITED:
                no_limited_count += 1
    return Result(limited_count, 
    no_limited_count, 
    start_count,
     stop_when_limited, 
     max_pulls,
        gacha.total_pulled)

def main():
    results = []
    for start_req in [40, 50, 60, 80, 100, 120]:
        for stop_flag in [True, False]:
            for max_pulls in [40, 50, 60, 70, 80, 100, 120]:
                if max_pulls < start_req:
                    # 最大回数まで溜まってない状況は無視
                    continue
                res = simulate(start_req, stop_flag, max_pulls)
                results.append(res)
    results.sort(key=lambda x: x.limited_count, reverse=True)
    total_count = 0
    rank = 0
    for r in results:
        rank += 1
        total_count += r.total_gacha_count
        print("RESULT[" + str(rank) + "]=" + str(r.limited_count) + "," + str(r.no_limited_count))
        print("  STRATEGY:" + str(r.start_req) + "," + str(r.stop_flag) + "," + str(r.max_pulls))
        if r.limited_count > 0:
            print("  AVG:" + str(r.total_gacha_count / r.limited_count))
    print("TOTAL:" + str(total_count))
if __name__ == "__main__":
    main()