I'm KUITARIDER.

がりゅうさんのサイキックミラクルブログ

Pokemon Showdown のシミュレーターをポケモン対戦ライブラリとして使う

想定読者: プログラミングを学習したことがある人

 

Pokemon Showdown! という非公式の対戦シミュレーターがあります。

f:id:shingaryu:20210704211406p:plain

 

このWebアプリケーションのソースコードGithubで公開されており(オープンソース)、誰でも閲覧、貢献(プルリクエストの送信)ができます。大きく分けると、以下の2つです。

  • サーバー (対戦受付サーバー、対戦シミュレーター)
  • クライアント(WebサイトのHTML, CSSなど※)

※詳しく言うと、preactというReact系のフレームワークを使用したシングルページアプリケーションの模様

 

このうちサーバーの方には、シミュレーター機能を単体でライブラリとして使用するためのAPIが実装されています。内訳は以下の通りです。

 

JavaScript用ライブラリ

・対戦シミュレーター…(a)

・Teamテキストの変換API

ポケモンの種族情報(Dex)の取得API

 

標準入出力経由(→言語不問)で操作するコマンドラインツール

・対戦シミュレーター

・Teamテキストの変換ツール

 

参考:

https://github.com/smogon/pokemon-showdown/blob/master/sim/README.md

https://github.com/smogon/pokemon-showdown/blob/master/COMMANDLINE.md

 

今回は、(a)のJavaScript用対戦シミュレーターライブラリを使用してみることにします。初心者向け解説になっています。

 

サンプルコードはこちらにあるので、使用例だけ見たい方はこちら。

github.com

 

Node.jsのインストール

Node.jsは、サーバーサイドJavaScriptと呼ばれるJavaScriptの実行環境になります。

JavaScriptは通常、Webブラウザを使って、HTMLから呼び出して実行するスクリプト言語ですが、それを通常のプログラミング言語と同様に、ローカルPCやサーバー内でも動かせるようにしたものです。

Pokemon ShowdownのサーバーソースコードはこのNode.jsで書かれているので、ローカルで実行するためにはインストールが必要になります。

Pythonのように、スクリプト言語を一つPCにインストールするようなものだと思っておけばいいです。

nodejs.org

 

Windowsの場合、インストーラーから簡単にインストールできるので、とりあえずLTSバージョンをDL→インストールしておけばいいでしょう。

f:id:shingaryu:20210704213601p:plain

 

Visual Studio Code のインストール

単に便利というだけで強制はしないのですが、ここから先はVisual Studio Code(以下VS Code)の使用をオススメします。(説明の上ではVS Codeを使っている前提で進めます)。

VS Codeは非常に高機能なテキストエディタです。厳密にはVisual studio (Codeでない方)、EclipseのようなIDEではないのですが、IDEだと思っても特に不都合はありません。

特にNode.js開発の場合、頻繁にターミナル(コマンドプロンプト)を使うので、ターミナルウィンドウ付きのVS Codeが便利です。

code.visualstudio.com

 

テスト用Node.jsアプリケーションの作成

適当な空フォルダを作って、そのフォルダをVS Codeで開きます。上メニュー>ターミナル>新しいターミナル からターミナルウィンドウも開いておきましょう。

f:id:shingaryu:20210704220141p:plain

 

ターミナルに以下のように入力し、Enterを押します。

npm init -y

package.jsonというNode.jsアプリケーション用の設定ファイルが作成されます。

f:id:shingaryu:20210704220404p:plain

 

次に、メインプログラムを記述するapp.js (名前はなんでも大丈夫です)を作成しておきます。そして以下のテストコードを入力します。

console.log('Hello world!!')

f:id:shingaryu:20210704220651p:plain

 

ターミナルに以下のように入力して実行します。すぐ下の行に"Hello world!!"と表示されれば成功です。ここまででうまくいかなかった場合は、Node.js、VS Codeのインストール、場合によってはPowershellやファイルの操作権限の問題を疑ってください。

node app.js

f:id:shingaryu:20210704220952p:plain



Showdown JavaScript用ライブラリの使用

参考:

https://github.com/smogon/pokemon-showdown/blob/master/sim/SIMULATOR.md

 

続いてターミナルで以下のコマンドを実行します。

npm install pokemon-showdown

これによりpokemon-showdown(サーバー)のソースコードがnode_modulesフォルダ内にDLされて、自作のコードから参照できるようになります。package.jsonが更新され、package-lock.jsonというファイルが新規作成されます。

これはnpmというパッケージマネージャの機能です(詳細は省きます)。node_modulesフォルダはpackage.jsonをもとにいつでも復元できるものなので、ソースコードをバージョン管理などするときにこのフォルダを含める必要はありません。

f:id:shingaryu:20210704221905p:plain

 

説明ページ(上記)にある以下のサンプルコードを動かしてみましょう。app.js内に貼り付けて、同様にnode app.jsで実行します。

const Sim = require('pokemon-showdown');
stream = new Sim.BattleStream();

(async () => {
    for await (const output of stream) {
        console.log(output);
    }
})();

stream.write(`>start {"formatid":"gen7randombattle"}`);
stream.write(`>player p1 {"name":"Alice"}`);
stream.write(`>player p2 {"name":"Bob"}`);

 

ターミナル出力に以下のような表示が現れれば成功です!7世代ランダムバトル(※)というルールで、対戦インスタンスが作成され、無事1ターン目がスタートしていることが確認できました。

 

※ シングルバトル、7世代、6vs6、パーティーポケモンはランダムに決定される

f:id:shingaryu:20210704222606p:plain

 

対戦ルールの指定、行動選択の指定、、など上のコードをカスタマイズしていくための知識を少し解説します。

 

BattleStreamの基本

対戦シミュレーターのインターフェイスは、Sim.BattleStreamクラスで定義されたストリームオブジェクト(※)です。

・ストリームへの書き込みをstream.write()で行い、…(1)

・ストリームからのメッセージはstreamオブジェクト自体をasync iterables オブジェクトとみなして取得できます。…(2)

※ Node.js純正のものではなく、独自実装のようです 

 

(1)で書き込むもの

・対戦の初期化

・行動選択

 

(1)の基本

・送信コマンドの先頭には">"をつける

・> 以降の内容はコマンド種類による

 

(1)で書き込む内容の例

>start {"formatid":"gen7ou"}
>player p1 {"name":"Alice","team":"insert packed team here"}
>player p2 {"name":"Bob","team":"insert packed team here"}
>p1 team 123456
>p2 team 123456

 

(2)で取得できるもの

・対戦メッセージ(HP、ターン数など)

・行動選択リクエス

 

(2)の基本

・通知種別?→対戦メッセージの順に改行で区切られて送信される。

・対戦メッセージの中身はさらに"|" で区切られ、メッセージ種類→メッセージに応じた内容 と続く。

 

(2)の例

update
|
|t:|1625406405
|move|p2a: Jolteon|Thunderbolt|p1a: Dialga
|-resisted|p1a: Dialga
|split|p1
|-damage|p1a: Dialga|193/270
|-damage|p1a: Dialga|72/100

...

 

上記でわかるように、(1)(2)ともにテキストで表現される独自のメッセージフォーマット(protocol)を把握する必要があります。これらは概ね以下のページで説明されています。

https://github.com/smogon/pokemon-showdown/blob/master/sim/SIM-PROTOCOL.md

 

今回の記事ではこのうち行動選択場面での要点のみを解説し、あとはサンプルコードの掲載で雰囲気を感じ取ってもらうことを想定しています。

 

行動選択

参考: 

https://github.com/smogon/pokemon-showdown/blob/master/sim/SIM-PROTOCOL.md#choice-requests

 

行動選択のきっかけは、|request|で始まる以下のような行動選択リクエストの受信です。

sideupdate
p2
|request|{"active":[{"moves":[{"move":"Light Screen","id":"lightscreen","pp":48,"maxpp":48,"target":"allySide","disabled":false},{"move":"U-turn","id":"uturn ...

 

これの|request| 以降のJSONテキストは次のような形をしています。

 

{
  "active": [
    {
      "moves": [
        {
          "move""Light Screen",
          "id""lightscreen",
          "pp"48,
          "maxpp"48,
          "target""allySide",
          "disabled"false
        },
        {
          "move""U-turn",
          "id""uturn",
          "pp"32,
          "maxpp"32,
          "target""normal",
          "disabled"false
        },
        {
          "move""Knock Off",
          "id""knockoff",
          "pp"32,
          "maxpp"32,
          "target""normal",
          "disabled"false
        },
        {
          "move""Roost",
          "id""roost",
          "pp"16,
          "maxpp"16,
          "target""self",
          "disabled"false
        }
      ]
    }
  ],
  "side": {
    "name""Zarel",
    "id""p2",
    "pokemon": [
      {
        "ident""p2: Ledian",
        "details""Ledian, L83, M",
        "condition""227/227",
        "active"true,
        "stats": {
          "atk"106,
          "def"131,
          "spa"139,
          "spd"230,
          "spe"189
        },
        "moves": [
          "lightscreen",
          "uturn",
          "knockoff",
          "roost"
        ],
        "baseAbility""swarm",
        "item""leftovers",
        "pokeball""pokeball",
        "ability""swarm"
      },
      {
        "ident""p2: Pyukumuku",
        "details""Pyukumuku, L83, F",
        "condition""227/227",
        "active"false,
        "stats": {
          "atk"104,
          "def"263,
          "spa"97,
          "spd"263,
          "spe"56
        },
        "moves": [
          "recover",
          "counter",
          "lightscreen",
          "reflect"
        ],
        "baseAbility""innardsout",
        "item""lightclay",
        "pokeball""pokeball",
        "ability""innardsout"
      },
      {
        "ident""p2: Heatmor",
        "details""Heatmor, L83, F",
        "condition""277/277",
        "active"false,
        "stats": {
          "atk"209,
          "def"157,
          "spa"222,
          "spd"157,
          "spe"156
        },
        "moves": [
          "fireblast",
          "suckerpunch",
          "gigadrain",
          "focusblast"
        ],
        "baseAbility""flashfire",
        "item""lifeorb",
        "pokeball""pokeball",
        "ability""flashfire"
      },
      {
        "ident""p2: Reuniclus",
        "details""Reuniclus, L78, M",
        "condition""300/300",
        "active"false,
        "stats": {
          "atk"106,
          "def"162,
          "spa"240,
          "spd"178,
          "spe"92
        },
        "moves": [
          "shadowball",
          "recover",
          "calmmind",
          "psyshock"
        ],
        "baseAbility""magicguard",
        "item""lifeorb",
        "pokeball""pokeball",
        "ability""magicguard"
      },
      {
        "ident""p2: Minun",
        "details""Minun, L83, F",
        "condition""235/235",
        "active"false,
        "stats": {
          "atk"71,
          "def"131,
          "spa"172,
          "spd"189,
          "spe"205
        },
        "moves": [
          "hiddenpowerice60",
          "nastyplot",
          "substitute",
          "thunderbolt"
        ],
        "baseAbility""voltabsorb",
        "item""leftovers",
        "pokeball""pokeball",
        "ability""voltabsorb"
      },
      {
        "ident""p2: Gligar",
        "details""Gligar, L79, M",
        "condition""232/232",
        "active"false,
        "stats": {
          "atk"164,
          "def"211,
          "spa"101,
          "spd"148,
          "spe"180
        },
        "moves": [
          "toxic",
          "stealthrock",
          "roost",
          "earthquake"
        ],
        "baseAbility""hypercutter",
        "item""eviolite",
        "pokeball""pokeball",
        "ability""hypercutter"
      }
    ]
  },
  "rqid"3
}

 

 

クライアント(今回の自作プログラム)は、このrequestオブジェクトをもとに次の行動をストリームに書き込むことになります。送信コマンドの一例は以下になります。

  • >p2 move lightscreen
  • >p2 move default
  • >p2 switch 2

 

ただし、上記のrequestオブジェクトの例に含まれていないパターンも多数あり、たとえば以下の状況の際にはrequestオブジェクトに追加の情報が付加されます。関連して送信コマンドに追加のパラメータが必要なケースもあります。この詳細は現状ではマニュアル化されていませんが、いくつかのパターンについては今回のサンプルコード内で実装したので、参考にしてください。

 

サンプルコード

github.com

 

執筆時点でのapp.jsの中身を貼り付けておきます。今まで同様に手元のapp.jsに貼り付けて実行すると、結果が表示されると思います。

(サンプルコードをGit Clone or ダウンロードした人向けの実行方法:)

npm install

node app.js

 

NUM_OF_BATTLESの回数だけ8世代ランダムバトルを繰り返して、最後にお互いの勝利数を出力するプログラムになっています。技選択はランダム、死に出し交代先はその時点で先頭のポケモンです。

現実的に対戦AIのようなものを作る場合は、onChoiceRequest関数内に独自の行動選択ロジックを実装していくことになります。

 

const Sim = require('pokemon-showdown');
stream = new Sim.BattleStream({keepAlive: true}); // 連続で対戦を行うときはkeepAliveオプションが必要

/*********************************************************************
 * 対戦インスタンスの設定 (好きな値に調整)
 * *******************************************************************/
const NUM_OF_BATTLES = 10;

const battleFormat = {
    formatid:"gen8randombattle" // 対戦ルールを表す内部IDを指定
}

const p1 = {
    name:"プレイヤー1"
}

const p2 = {
    name:"プレイヤー2"
}

/*********************************************************************
 * 集計用 (特別な意味はありません)
 * *******************************************************************/
let battleCount = 0;

const winCount = {
    [p1.name]: 0,
    [p2.name]: 0
}

/*********************************************************************
 * BattleStream操作用ロジック
 * *******************************************************************/
function writeAndLog(command) {
    console.log(`>> ${command}`)
    stream.write(`>${command}`// > を先頭につけるのを忘れずに
}

function startNewBattle() {
    console.log(`新規の対戦を開始します(${battleCount + 1}回目)…`);
    writeAndLog(`start ${JSON.stringify(battleFormat)}`)
    writeAndLog(`player p1 ${JSON.stringify(p1)}`)
    writeAndLog(`player p2 ${JSON.stringify(p2)}`)
}

// メガシンカダイマックスも含めて正確に行動選択を制御する場合は、choiceRequestの構造の把握が必要
function onChoiceRequest(choiceRequest) {
    const player = choiceRequest.side.id
    if (choiceRequest.forceSwitch) { // 死に出し選択状態
        writeAndLog(`${player} default`// 例としてリスト一番上のポケモンに交代
        return
    } else if (choiceRequest.wait) { // 行動選択なし (例)げきりんの発動中
        return
    } else if (choiceRequest.teamPreview) { // 選出選択、一部ルールでは存在
        // 選出選択が存在するルールでは適当なロジックを書く
        return
    } else if (!choiceRequest.active) {
        return
    }

    const availableMoves = choiceRequest.active[0].moves.filter(move => !move.disabled)
    const rand = Math.floor(Math.random() * availableMoves.length// 例としてランダム技選択
    const move = availableMoves[rand];
    writeAndLog(`${player} move ${move.id}`)
}

function onBattleWin(winnerName) {
    console.log(`対戦結果: ${winnerName}の勝ち`)
    winCount[winnerName]++
    battleCount++
}

// 参考: シミュレータープロトコル
const streamOutputHandler = async () => {
    for await (const output of stream) { // cf. async iterables 
        console.log(`<< ${output}`)
        const lines = output.split('\n')

        if (lines[0] === 'end') {
            if (battleCount < NUM_OF_BATTLES) {
                startNewBattle();
            } else {
                console.log('すべての対戦が終了しました。');
                console.log('勝利数:')
                console.log(winCount);
            }
            continue;
        }

        lines.forEach(line => {
            const records = line.split('|')
            if (records.length < 2) {
                return;
            }

            switch (records[1]) {
                case 'request':
                    const request = JSON.parse(records[2])
                    onChoiceRequest(request)
                break;
                case 'win':
                    onBattleWin(records[2])
                break;
                default:
                    return;
            }

        });
    }
};
streamOutputHandler();
startNewBattle();
 
 
 
 
 
 
 
 
次回につづくかもしれない