FabfibというボードゲームのシミュレータをPythonで書いているという話

## Fabfibシミュレータfor研究

Fabfibというゲームの研究用の(プレイ用でない)シミュレータを2017年くらいから書いています。プレイをしたい場合はFabfibの購入をよろしくお願いします。

## Fabfibとは

自身の[ポートフォリオサイト](http://sigma-portfolio.s3-website-us-west-2.amazonaws.com/mywork/fabfib)に詳細を書いています。

## 動機

宣言(call)捨て札(discard)およびダウト(doubt)のそれぞれの戦略について興味を持ちました。

## どこまで書いたか

自分がプレイする時にどのような戦略を採用しているかを参考に、それぞれに基本的な戦略を定義しました。それを元にゲームを回すところまでを書きました。

## 何を使ったか

依存している外部ライブラリはnumpyのみで、他はPythonの標準ライブラリとなっています。Pythonは3系です。

```
pip3 install numpy
```

なぜnumpyを使ったかと言えば、numpyの行列で戦況を格納すれば、分析するときに便利だと考えたからです。

## Call Strategy

前の宣言よりも低い宣言は無効です。いつも前の宣言よりも高い手札を引けるとは限らないので、Call Strategyでは、状況に応じて嘘の宣言をすることが求められます。

「捨てた札を引き直した結果、より高い数字の札を引いた。」と見せかける戦略を基本的な戦略として採用しています。

問題は、どのくらい高い数字に見せかけるかです。これを数理的に考えると、ポアソン分布に基づく乱数のような構造をしていると考え、採用しました。

例えば、4を捨てて6を引いたと見せかけるより、9を引いたと見せかける方が心理的な障壁が高いと考えられます。確率を考えるとより低い数字を引く確率が高いとは言えませんが、次の人になんとか受け取ってもらおうという心理を考えると、一定の合理性があるように感じます。

採用したロジックはこんな感じです。

```python
class BasicLogic(CallStrategy):
    def __init__(self, Number, DiscardCount, Hand):
        super().__init__(Number, DiscardCount, Hand)

    def call_check(self):
        """Basic call_check method
        >>> import numpy as np
        >>> RegularCase = BasicLogic(431,1,np.array([6,3,1]))
        >>> print(RegularCase.call_check() == 631)
        True
        >>> IregularCase = BasicLogic(831,1,np.array([6,3,1]))
        >>> print(631 < IregularCase.call_check() <= 999)
        True
        >>> NineCase = BasicLogic(995,1,np.array([9,9,4]))
        >>> print(995 < NineCase.call_check() <= 999)
        True
        >>> AllChange = BasicLogic(654,3,np.array([4,3,1]))
        >>> print(654 < AllChange.call_check() <= 999)
        True
        """
        EvaluatedValue = super().evaluate_hand(self.Hand)

        # if real hand's value is weak,
        # Calling fake number based on Game's State
        if self.Number >= EvaluatedValue:

            # Leaves to change is based on NonNines Count of
            # Game's Number or DiscardCount.
            ToCall = np.array([int(i) for i in str(self.Number)])
            ToCall = np.sort(ToCall)  # ascending order
            NonNines = len(np.where(ToCall != 9)[0])
            LeavesToChange = min(self.DiscardCount, NonNines)

            for i in range(0, LeavesToChange - 1):
                ToCall[i] = np.random.poisson(lam=4)
                if ToCall[i] > 9:
                    ToCall[i] = 9

            ToCall[LeavesToChange - 1] += 1
            ToCall[LeavesToChange - 1] += np.random.poisson(lam=0.2)
            if ToCall[LeavesToChange - 1] > 9:
                ToCall[LeavesToChange - 1] = 9

            return super().evaluate_hand(np.sort(ToCall)[::-1])

        else:
            return EvaluatedValue
```


## Discard Strategy

受け取った手札からどれを捨てるのかを決定する戦略です。

基本的な戦略として採用しているのはシンプルな戦略で、まず6より小さい数字は全て捨て、もし一番大きい数字と一番小さい数字の差が4未満であれば、9以外を全部捨てます。

例えば876という手札であれば、全て捨てて9を1枚以上引けば良いので、その確率にかけます。まずまずの順当な戦略と言えると思います。

採用したロジックはこんな感じです。

```python
class BasicLogic(DiscardStrategy):
    """Players on BasicLogic strategy discard ...

    less then 6, because a high possibility to draw any of 5-9.
    If the difference of Max and Min is less then 4, other then nine.
    To raise the posibility of 9.
    """

    def __init__(self, Hand):
        super().__init__(Hand)
        self.Max = np.max(Hand)
        self.Min = np.min(Hand)

    def discard_check(self):
        """Basic discard check logic

        >>> import numpy as np
        >>> CloseCase = BasicLogic(np.array([6,5,4]))
        >>> CloseCase.discard_check()
        array([5, 4, 6])
        """
        self.less_then(6)
        if((self.Max - self.Min) < 4):
            self.less_then(9)
        return self.Discard

    def less_then(self, Number):
        self.Discard = np.append(self.Discard, self.Hand[self.Hand < Number])
        self.Hand = self.Hand[self.Hand >= Number]
```

## Doubt Strategy

宣言に対してダウトを仕掛ける戦略です。ダウトを仕掛ける場合、ダメージを受けるリスクを負うので、ダウトを仕掛ける際は慎重になります。嘘をついてる可能性が高いと判断しても、宣言されている数字が低ければ受けとることがあり得るでしょう。

つまりダウトを仕掛ける判断には、宣言されている数字が重要になります。以下の式を用いてダウト指数とし、状況を加味してそれを補正する方法を採用しました。

採用したロジックはこんな感じです。

```python
class BasicLogic(DoubtStrategy):
    def __init__(self, Call, DiscardCount):
        super().__init__(Call, DiscardCount)

    def doubt_check(self):
        """Basic doubt_check
        >>> import numpy as np
        >>> TwoDiscardCase = BasicLogic(992,2)
        >>> type(TwoDiscardCase.doubt_check())
        <class 'bool'>
        >>> ThreeDiscardCase = BasicLogic(982,3)
        >>> type(ThreeDiscardCase.doubt_check())
        <class 'bool'>
        """
        DoubtIndex = 0.0
        DoubtIndex += (np.random.rand() * 0.2 + 0.8) * \
            (((self.Call - 700) / 300) ** 3)

        if self.DiscardCount == 2:
            DoubtIndex += 0.1
            if self.Call >= 990:
                DoubtIndex += 0.6

        if self.DiscardCount == 3:
            DoubtIndex += 0.3
            if self.Call >= 900:
                DoubtIndex += 0.3

        if DoubtIndex > 0.85:
            self.Doubt = True

        return self.Doubt
```

## 動かしかた

numpyがインストールされていることを前提とします。cloneします。

```
git clone https://github.com/say-ya-sigma/fabfib.git
cd fabfib
```

`game.py`で動かします。引数はゲーム数です。

```
python3 game.py 10