CodinGame でローカル対戦環境を作る

CodinGame / コドゲ というゲームAIを作って戦わせるコンペを定期的に開いているウェブサイトがあります。 この記事の執筆時点では Spring Challenge 2021 が開催中です。

コドゲはかなりリッチなサイトで、ブラウザ上のエディタで実装してテスト対戦実行・本番提出まで可能です。

f:id:koyumeishi:20210509012700p:plain
CodinGame / Spring Challenge 2021

ローカルにプログラミング環境がなくても実行できるのでめっちゃ便利で初心者にも優しいのですが、 サイトが混みあっているとき (終了間際とか) なんかはなかなか結果が返って来なくて厳しいですし、 何十回も対戦させて検証したいときなんかはちょっとやってらんないです。
多くの上位者はゲームのシミュレータを自分で実装してこれを解決していますが (多分)、 実はコドゲは GitHub 上でゲームのコードを公開していることがあるので、 これを利用して簡単にローカル対戦環境を作っちゃいましょう。 (当然ですが他ユーザーのコードは提供されないので、 サイト上での様に他ユーザーと戦わせるとかはできません。主に自己対戦用。 実行環境とかも違うと思うので注意。)

やること

  1. Java 開発環境構築
  2. Maven 環境構築
  3. 公式githubリポジトリからコードをダウンロード
  4. 対戦のエントリポイントの編集
  5. Maven で実行可能な jar をビルドして実行

今回の Spring Challenge 2021 では https://github.com/CodinGame/SpringChallenge2021 にコードが公開されています。
リポジトリpom.xml というファイルが見えます。 これは Maven という Java のプロジェクト管理ツールに使われているものなので、 Java (JDK) と Maven が必要です。 環境にない人はインストールしましょう。

僕は Windows 環境で、パッケージマネージャに Scoop を使っているのでそれで書きます (環境変数とかも設定してくれて便利)。 違う環境の人は適当に公式サイトからインストーラをダウンロードするとか apt とか packman とかを使うとか読み替えてください。

Java 開発環境

上述の通り Java 開発環境が必要なのでインストールします。 最近は openjdk とか何か色々派生してます。 過去の僕が何故か corretto11 を入れていましたが、最近のなら多分何でもいいと思います。 (どれがいいのかよくわかっていない)

scoop に java bucket を追加

$ scoop bucket add java

適当な JDK をインストール (なんでもいいと思う)

$ scoop install corretto11

Maven 環境

上述の通り Maven でプロジェクト管理しているようなので、依存パッケージを解決するのに使います。

$ scoop install maven

公式githubリポジトリからコードをダウンロード

コドゲ公式のリポジトリを clone します。 今回はこちら
https://github.com/CodinGame/SpringChallenge2021

$ git clone --depth 1 https://github.com/CodinGame/SpringChallenge2021
$ cd SpringChallenge2021

最新版だけあればいいので --depth 1 を指定してますが無くても。 git インストールしてなくても github から zip でダウンロードできると思います。

対戦のエントリポイント

src/main/java 配下にゲーム用のコードが入っているのですが、対戦用のエントリポイントは src/test/java の方に入っています。
今回の場合は これ https://github.com/CodinGame/SpringChallenge2021/blob/main/src/test/java/Spring2021.java

static String[] DEFAULT_AI = new String[] {
    "python3", "config/Boss.py"
};

とかで AI の実行コマンドを指定していますね。 ここを書き換えて自分のに置き換えましょう。

この段階で VSCode (+Java拡張) 等 Maven 対応の IDE で開いて実行できます。

f:id:koyumeishi:20210509040846p:plain
vscode

pom.xml によると codingame-game-engine ver 4.0.2 を参照しているようです。 (過去回や未来回はバージョンが違うと思うので注意。 tag 付けてくれてるので対応したのを参照してね)
コードを追いかけたりドキュメントを見たりするとちょっと改造できます。

例えば Spring2021.java では MultiplayerGameRunnerMultiplayerGameRunner.start() を呼ぶことで localhost:8888 に結果を表示していますが、 ビジュアライズ部分が不要な場合 MultiplayerGameRunner.simulate() を呼べば良いです。 戻り値の GameResult 型にはスコア情報などが入っているので、これをコンソールに出力等すれば結果がわかります。 連戦させた結果が欲しい場合はこれのループを回せばいいです。

Maven で実行可能な jar をビルドして実行

さて、ここまででも IDE 経由の実行で対戦実行させることはできましたが、 対戦実行の度このプロジェクトを開きたくないので実行可能な jar ファイルをビルドします。 (オプションをめっちゃ指定してやるとかでもできるんだけど、長くて面倒)

[重要] Maven詳しくないんですが、 src/test に入っているコードはビルド時に除外されてしまうみたいなので エントリポイントのファイルを src/main/java に移動しておきます。

src/test/java/Spring2021.java -> src/main/java/Spring2021.java

次のコマンドでビルド (不完全?) できます。

$ mvn assembly:assembly -DdescriptorId=jar-with-dependencies

これで target/spring-2021-1.0-SNAPSHOT-jar-with-dependencies.jar のようなファイルをビルドしてくれます。
参考: Maven Assemblyとは?

実行するときは

$ java -cp 生成されたjarへのパス Spring2021

の様にして実行できます。お疲れさまでした。 (Spring2021 はエントリポイントのクラス名。 もし変えてたらここも変更)


なんか jar にパッケージしたときビジュアライズ用のファイルの相対位置がおかしくなるみたいで、 一部画像が表示されませんでした。(自分の環境だけ?謎。)

次のような assembly.xml を別に用意して解決できました。

<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0 http://maven.apache.org/xsd/assembly-2.1.0.xsd">
  <!-- TODO: a jarjar format would be better -->
  <id>jar-with-dependencies</id>
  <formats>
    <format>jar</format>
  </formats>
  <includeBaseDirectory>false</includeBaseDirectory>
  <dependencySets>
    <dependencySet>
      <outputDirectory>/</outputDirectory>
      <useProjectArtifact>true</useProjectArtifact>
      <unpack>true</unpack>
      <scope>runtime</scope>
    </dependencySet>
  </dependencySets>
  <fileSets>
    <fileSet>
      <directory>${project.basedir}/src/main/resources/view</directory>
      <useDefaultExcludes>true</useDefaultExcludes>
      <outputDirectory>/view/assets</outputDirectory>
    </fileSet> 
  </fileSets>
</assembly>

ビルドするときは

$ mvn assembly:assembly -Ddescriptor=assembly.xml

に変わります。

f:id:koyumeishi:20210509043550p:plain
ローカル対戦

tips

ビジュアライザ見ながらステップ実行でデバッグ、 みたいなことをしたくてもそもそも実行元が java のサブプロセス呼び出しなので (簡単には) できません。

間に何かを噛ませればできそうに思うんだけど (Spring2021 -> A <===> B with debugger, AB間はパイプとかファイルとか通信とか人力コピペとか)、実はタイムアウトの処理も提供されたコードに組み込まれているので 50-100ms のターン毎の時間制限に引っ掛かります。

このへん

// src/main/java/com/codingame/game/Referee.java
    public void init() {
        this.seed = gameManager.getSeed();

        try {
            Config.load(gameManager.getGameParameters());
            Config.export(gameManager.getGameParameters());
            gameManager.setFirstTurnMaxTime(1000);
            gameManager.setTurnMaxTime(100);

            gameManager.setFrameDuration(500);

弄ればよさそうに見えるんですが、実はゲームエンジンの方で上下限 (50ms - 25s / turn, 30s / game) が決められていてできません。
src/main/java/com/codingame/gameengine/core/GameManager.java をローカルにコピーして、以下のあたりを適宜コメントアウトしたり数値をいじればよさそう?(試してない)

// L38
    private static final int GAME_DURATION_HARD_QUOTA = 30_000;
    private static final int GAME_DURATION_SOFT_QUOTA = 25_000;
    private static final int MAX_TURN_TIME = GAME_DURATION_SOFT_QUOTA;
    private static final int MIN_TURN_TIME = 50;

    private int turnMaxTime = 50;
    private int firstTurnMaxTime = 1000;

// L456
    public void setTurnMaxTime(int turnMaxTime) throws IllegalArgumentException {
        if (turnMaxTime < MIN_TURN_TIME) {
            throw new IllegalArgumentException("Invalid turn max time : stay above 50ms");
        } else if (turnMaxTime > MAX_TURN_TIME) {
            throw new IllegalArgumentException("Invalid turn max time : stay under 25s");
        }
        this.turnMaxTime = turnMaxTime;
    }

// L473
    public void setFirstTurnMaxTime(int firstTurnMaxTime) throws IllegalArgumentException {
        if (firstTurnMaxTime < MIN_TURN_TIME) {
            throw new IllegalArgumentException("Invalid turn max time : stay above 50ms");
        } else if (firstTurnMaxTime > MAX_TURN_TIME) {
            throw new IllegalArgumentException("Invalid turn max time : stay under 25s");
        }
        this.firstTurnMaxTime = firstTurnMaxTime;
    }

// L579
    private void addTurnTime() {
        totalTurnTime += turnMaxTime;
        if (totalTurnTime > GAME_DURATION_HARD_QUOTA) {
            throw new RuntimeException(String.format("Total game duration too long (>%dms)", GAME_DURATION_HARD_QUOTA));
        } else if (totalTurnTime > GAME_DURATION_SOFT_QUOTA) {
            log.warn(
                String.format(
                    "Warning: too many turns and/or too much time allocated to players per turn (%dms/%dms)",
                    totalTurnTime, GAME_DURATION_HARD_QUOTA
                )
            );
        }
    }