TypeScript+React+Cloud Firestore+Stateモナドで、ブラウザで動くボードゲームの対戦ツールを作った (2/3 メカニクス編)

# 概要

[前回](https://qiita.com/zonaryFUND/items/78f0e5cb19fa1d8421cd)はボードゲーム、[ナショナルエコノミー](https://bodoge.hoobby.net/games/national-economy)オンライン対戦ツール全体の構成、および用いるエンティティの定義について解説しました。
今回はゲームメカニクス部分とそのテストをStateモナドを用いて実装する方法について解説します。

# はじめに

ゲームメカニクス部は、先に定義した各ゲーム状態エンティティを用いて様々な種類のStateモナド、あるいはその生成関数を作成する形で行います。また、その動作確認は単体テストの記述によって簡単に行えます。
UIや通信部にはまだ手を出さないので、Reactアプリケーションを書くときのようにwebpack-dev-serverでローカルサーバを立てたりするような準備が必要なわけではありません。

Stateモナド自体の定義は自分で1から書きますし、ゲームの各種操作はその組み合わせによって実現されます。Stateモナドを提供するライブラリはたぶん世の中にあるとは思いますが、ここでは勉強もかねて自分で書きます。
外部ライブラリとしては、単体テスト用の[Mocha](https://mochajs.org/)、[power-assert](https://github.com/power-assert-js/power-assert)のみ導入しました。

# Stateモナドの定義

メカニクス部をStateモナドを用いて完全に副作用なしで書くのであれば、本来は[PureScript](https://www.purescript.org/)などを用いるべきなのでしょうが、~~習得が面倒なので~~ここではTypeScriptでジェネリクスを用いて記述します。

```typescript:state.ts
export default class State<S, T> {
    readonly run: (s: S) => [T, S];
    constructor(run: (s: S) => [T, S]) {
        this.run = run;
    }

    static returnS<S, T>(t: T): State<S, T> {
        return new State(s => [t, s]);
    }

    flatMap<Tr>(f: (t: T) => State<S, Tr>): State<S, Tr> {
        return new State(s => {
            const [tr, sr] = this.run(s);
            return f(tr).run(sr);
        });
    }

    static get<S>(): State<S, S> {
        return new State(s => [s, s]);
    }

    static put<S>(s: S): State<S, void> {
        return new State(_ => [undefined, s]);
    }

    modify(f: (s: S) => S): State<S, T> {
        return new State(s => {
            const [t, sin] = this.run(s);
            return [t, f(sin)];
        });
    }

    map<Tr>(f: (t: T) => Tr): State<S, Tr> {
        return new State(s => {
            const [t, sr] = this.run(s);
            return [f(t), sr];
        });
    }
}
```

~~今見ると型引数の`S`と`T`が逆な気がしますが~~Haskellの同等メソッド名には合わせたり合わせなかったりしています。

# ゲーム内操作の実装

Stateモナドが記述できたところで、次はこれをどのように用いてゲーム内操作を構成すればよいかを説明します。
書いたはいいけど、結局Stateモナドって何なの？という説明はこれまで意図的に省略していました。これは厳密に正しい説明をする自信がなかった、ということもありますが、ひとえに私自身の「様々な解説や記事を読んでも意味がよく理解できなかった」という経験により、とりあえず便利に使う方法を先に示せば、理解はあとから付いてくるだろうという見通しに基づいています。
これに従い、いきなり具体的なゲーム内操作の記述からはじめます。

## プレイヤー状態の更新

### 持ち金の増加

世のボードゲームには、手札や持ち金、資源トークンなど様々な「プレイヤーの状態」を表現するシステムが存在しています。ナショナルエコノミーも例に漏れずそれらの要素を備えています。
プレイヤー状態を更新する最も単純な例として、まずは「家計から金を獲得して持ち金に加える」動作を表現するStateモナドの作成メソッドを、先に定義したStateモナドの各メソッドを用いて実装してみます。
ゲームシステム上「家計から金を獲得」する際には、同時に同量の金を家計から取り除く必要がありますが、ここでは対象プレイヤーの状態についてのみ見ます。

```typescript:持ち金増加
function earn(amount: number): State<Player, string> {
    return new State<Player, string>(player => [`${player.name || player.id}が家計から$${amount}を獲得しました`, {...player, cash: player.cash + amount}]);
}
```

先に定義したTypeScriptの`State<S, T>`は、「元状態$s\in S$を引数として受け取り、改変後の状態$s'\in S$を、何らかの付加情報$t \in T$と共に返す関数」を内部に閉じ込めた`class`として解釈されます。これを「適用」するとは、現在の状態$s_\mathrm{current}\in S$を閉じ込めた関数`run`に渡し、出力を取り出すことを意味しています。
上のメソッドは「プレイヤーの元状態を受け取り、改変後のプレイヤー状態とログ文字列を返す関数」を閉じ込めています。任意のプレイヤー状態に対して、求める改変されたプレイヤー状態は単純な[スプレッド構文](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax)で作成できます。
これによって得られたStateモナドを実際に用いて改変後のプレイヤー状態を次のように得ることができます。

```typescript:改変後のプレイヤー状態の生成
const previousPlayer: Player = ...
...
const modifiedPlayer = earn(10).run(previousPlayer);
```

また、`earn`メソッドの作成方法には`State`のコンストラクタ以外にも表現方法があります。

```typescript:earnの別表現
function earn(amount: number): State<Player, string> {
    return State.get<Player>()
        .modify(player => {...player, cash: player.cash + amount})
        .map(player => `${player.name || player.id}が家計から$${amount}を獲得しました`);
}
```

この表現方法では、`get`メソッドで「`run`で渡されるプレイヤー状態」を型引数`T`に渡し、`modify`メソッドでプレイヤー状態を更新し、`map`メソッドで`T = Player`(と`amount`)からログ文字列を導出するような抽象的なイメージが可能です。

あるいは、`flatMap`を用いても表現できます。

```typescript:earnの別表現(2)
function earn(amount: number): State<Player, string> {
    return State.get<Player>()
        .flatMap(player => {
            const nextPlayer = {...player, cash: player.cash + amount};
            return State.put<Player>(nextPlayer)
                .map(_ => `${player.name || player.id}が家計から$${amount}を獲得しました`)
        });
}
```

`get`メソッドは前のものと同様ですが、`flatMap`メソッドに「元状態`player`から新状態`newPlayer`を導出する。それを`put`メソッドを用いて『一連の`State`において、以降に渡す`Player`』として置き換え、さらに前と同様に`map`でログ文字列を導出する」ような`State`の変換・生成関数を渡しています。

### 手札の破棄

プレイヤー状態の変更における他の例として、何らかの原因により手札を捨て札にする場合を考えます。
手札のうち捨て札とするカードは当該プレイヤーの意思によって選択されます。したがって、その実装にはプレイヤーの操作待ち状態を介入させる必要があります。
これは後でも説明しますが、ゲームの全状態`Game`の`state`メンバを用いることで表現します。`State`と同じ名前を使用してしまっていますが、無関係であることに注意してください(詳細は前回参照)。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73350/cba6f1c6-908d-297a-e27b-ca0f0a51fdc4.png)

捨て札の選択など、プレイヤーによる意思決定が途中で必要となるような操作を実現する場合、「元状態を操作待ち状態にするStateモナド」と「操作待ち状態に対して、プレイヤー操作によってその解決をした状態にするStateモナド」の2つに分け、前者は`state`に間の「操作待ち状態」を代入するStateモナドとして表現します。
この状態のときに操作を行うと、操作に応じたStateモナドを後者の「解決」Stateモナドとして表現します。

これら2つのモナドによって、途中にプレイヤーの意思決定を要求するゲーム内操作が表現されます。
前半のモナドはゲームの全状態`Game`に改変を加える必要があるため後回しとし、ここでは後半のモナドを作成できるようにします。

```typescript:手札の破棄(解決)
function disposeHand(targetHandIndices: number[]): State<Player, string> {
    return State.get<Player>()
        .flatMap(player => {
            const removedCards = targetHandIndices.map(i => player.hand[i]).join(",");
            const nextHand = player.hand.filter((_, i) => !targetHandIndices.includes(i));
            const nextPlayer = {...player, hand: nextHand};
            return State.put<Player>(nextPlayer)
                .map(_ => `${player.name || player.id}が手札から${removedCards}を破棄しました`)
        })
}
```

### 合成

ここで、作成した「家計から金を得る」「選択した手札を破棄する」という2つのStateモナドを結合して「選択した手札を破棄し、家計から金を得る」という新たなStateモナドを簡単に得ることができます。

```typescript:結合
const disposeState = disposeHand([0]);
const earnState = earn(6);

export const disposeAndEarn = disposeState.flatMap(disposeLog => earnState.map(earnLog => disposeLog + "\n" + earnLog)); 
// 手札の最初のカードを捨て、$6得る
```

上のように、単純に`flatMap`で繋げてログを適当に整形するだけでよく、部品となる単純なStateモナドをひととおり用意すれば、ゲーム内に存在する「プレイヤー状態の変更」がその組み合わせだけでいくらでも作成できます。

### テスト

このようにして作成された`State`のテストは、適切かつ単純な元状態$s\in S$を作成し、それを引数とした`run`メソッドの結果が、求める後状態と一致しているかどうかを確認するだけで書くことができます。

```typescript:test-dispose-earn.ts
import "mocha";
...

describe("手札を1枚捨てて$6獲得", () => {
    const originState: Player = {
        id: "red",
        name: "田中太郎",
        hand: ["農場", "焼畑"],
        cash: 10,
        ...
    };
    const result = disposeAndEarn.run(originState);
    it("State.runの結果が「0番カードを捨てて持ち金が$6増えた状態」である", () => {
        assert.deepEqual(result[1], {
            id: "red",
            name: "田中太郎",
            hand: ["焼畑"],
            cash: 16,
            ...
        }, "出力状態が異なる");
        assert.equal(result[0], "田中太郎が手札から農場を破棄しました\n田中太郎が家計から$6を獲得しました", "ログが異なる");
    });
);
```

## ゲーム全状態の更新

### `State`型の変換

これで「様々な操作によるプレイヤー状態の更新」が簡単に書けるようになりました。一方で、前の記事で示した通り、Cloud Firestoreを通じて共有されるゲームの全状態は、`Player`をメンバに持つ`Board`、それをさらにメンバに持つ`Game`でした。個人の状態ではなくゲーム全体に影響を与えるようなゲーム内操作も多く存在することを考えると、それらはすべて`State<Game, string>`として表現することで、結合が簡単になるとともに、DBの更新方法も1種類に限定することができます。

つまり、`State<Player, string>`をどうにかして`State<Game, string>`に変換する、いわば昇格させる必要があります。`State<S, T>`の`T`は`map`や`flatMap`によって変換できますが、`S`はどのように変換するのでしょうか？

ここで、`Player`と`Game`は一連の親子関係にあることを思い出しましょう。`Game`から`Board`を辿って、改変したい`Player`へたどり着き、その`Player`だけが改変された新たな`Game`を次のように導くことができます。

```typescript:apply-player-in-game.ts
function affectPlayer<T>(state: State<Player, T>, to: PlayerIdentifier): State<Board, T> {
    return State.get<Board>()
        .flatMap(board => {
            const targetPlayer = Object.values(board.players).findIndex(p => p.id == to);
            const result = state.run(board.players[targetPlayer]);
            const nextBoard = {...board, players: {...board.players, [targetPlayer]: result.1}};
            return State.put(board).map(_ => result.0);
        });
}

function affectBoard<T>(state: State<Board, T>): State<Game, T> {
...
}
```

これによって、`State<Player, T>`を、`State<Board, T>`または`State<Game, T>`へ変換することが可能になりました。
「プレイヤー1人にだけ影響するゲーム内操作」を`State<Game, string>`で記述すると、そのたびに対象プレイヤーを`Game`から導出しなければならないため、そのような操作は`State<Player, string>`で記述してから変換する方が単純に記述できます。

### 操作待ち状態の表現

最後に、「途中でプレイヤーの意思決定が要求される一連の操作」の表現を行いましょう。
とはいえ、その方針は既に示したように、「中間状態を作成して一連の操作を前半Stateと後半Stateに分ける」という単純なものです。後半Stateは既に示しており、さらに今ではそれを`State<Game, string>`へ変換することもできますから、あとは前半部分を`State<Game, string>`として実装すればおしまいです。前半・後半の操作の呼び分けはUI層で行うため、メカニクスの実装においては単純に、前半と後半それぞれのStateが生成できればOKです。

```typescript:choose-to-dispose.ts
function marketFirstHalf(): State<Game, string> {
    return State.get<Game>()
        .flatMap(game => { 
            const prevState = game.currentState as InRoundState;
            const player = game.board.players.find(p => p.id == prevState.currentPlayer);
            return State.put({...game, state: {...prevState, phase: "oncardeffect", effecting: "市場"}})
                .map(_ => `${player.name || player.id}は手札から破棄する2枚のカードを選んでいます`);
        });
}

function marketSecondHalf(disposedHandIndices: number[], playerId: PlayerIdentifier): State<Game, string> {
    const state = disposeHand(disposedHandIndices).flatMap(disposeLog => earn(12).map(earnLog => disposeLog + "\n" + earnLog));
    return affectBoard(affectPlayer(state, playerId));
}
```

`Game`は「中間状態ではその原因となったカード名を代入する」という要件で定義しているので、ここでは具体的なカードの例として市場(手札を2枚選んで捨て、家計から$12獲得する効果)を採用しました。

`marketSecondHalf`の戻り値が`State<Game, string>`であり、かつ「市場」の効果が発動するのはプレイヤーターン中だけ(`Game.state`が`InRoundState`)であることから、引数`playerId`なしでも求める`State<Game, string>`を導出できます。ここでは省略しますが`marketFirstHalf`を見れば大まかな方針が分かると思われます。よかったら考えてみてください。

# まとめ

今回は、Stateモナドを利用してゲーム内の各種操作を記述する手法について解説しました。
最終回はUIと通信部分を実装し、オンライン対戦ツールとして動くようにします。

# 参考文献

[Haskell 状態系モナド 超入門](https://qiita.com/7shi/items/2e9bff5d88302de1a9e9)