Perl Hackers Hub

第48回Perlでの今風のゲームサーバ開発とテスト(3)

(1)こちら⁠2)こちらから。

マスタデータのテスト

ゲーム運営において必要になってくるのがアイテム定義などを含むマスタデータの管理です。ここでは、マスタデータの管理やテストの手法について述べていきます。

マスタデータはゲームサーバにおいて、ゲームの挙動を決める要素の一つです。プログラムコードと違い、プログラマー以外が入力できる形式で書かれる場合があり、ときには専用の入力ツールや、Unityエディタなどのゲーム開発環境に統合された内製ツールが入力に用いられることがあります。

マスタデータの例

筆者のプロジェクトでは、マスタデータはCSVComma-Separated Valuesカンマ区切り)の形式で記述され、そのままMySQLのテーブルにレコードとして挿入できる形で表現されます。以下に、ゲーム内アイテムを表現するマスタデータの例を示します。

id,name,effect_type,effect_parameter
1,小さい体力回復ドリンク,1,{"energy": 30}
2,ふつうの体力回復ドリンク,1,{"energy": 50}
3,大きい体力回復ドリンク,1,{"energy":100}

このアイテムテーブルはidnameeffect_type、effect_parameterで構成されています。idは、アイテムを区別するサロゲートキーです。nameはアイテム名です。

アイテムの種類や挙動を決めるのに、effect_typeカラムとeffect_parameterカラムを用意しています。effect_typeはプログラム内で列挙型として定義され、1は体力回復アイテムを指します。effect_parameterは、アイテムをeffect_typeに沿って適用するときの引数で、JSON形式で書かれています。SQLアンチパターンで言われているものの一つではありますが、アイテムごとに発揮する効果が違い、設定される値の数や意味も違うため、このようになっています。この場合、小さい体力回復ドリンクは体力を30回復しますが、大きい体力回復ドリンクは体力を100回復します。

ほかにもアイテムのマスタデータには、売却する際にゲーム内通貨としていくらで売れるかなどが定義されます。

実際に使用するときには、上記に示したCSVをitemsテーブルに格納します。このitemsテーブルに対応するユーザーデータのテーブルをuser_itemsとします。user_itemsテーブルは、サロゲートキーidアイテムを所持しているユーザーを示すuser_id所持しているアイテムを示すitem_id所持数を示すhaving_numカラムで構成されています。

マスタデータを用いたコード

では、上記のマスタデータを用いて、どのようにゲームの挙動を変化させるかを見ていきます。

回復アイテムの消費と効果をユーザーに適用する関数の例

アイテム消費APIから呼ばれる、アイテム消費関数を考えます。関数は次の処理に分解できます。

txn_begin; # トランザクションを開始する
# アイテムの行があればそれに対して行ロックをかける
$user_item->lock;
# アイテムを所持しているかをチェックする
$user_item->try_having_by_num($num);
# 1つずつアイテムを消費して効果を発揮させる
my $result =
    $user_item->consume_and_effect(num => $num);
txn_commit; # トランザクションを終了してコミットする

実際にアイテム消費と効果適用を行う関数

アイテムを消費して効果を発揮させるconsume_and_effectメソッドの中身を次に示します。

# アイテムを消費する
# UPDATE user_items
#   SET having_num = having_num - $num
#   WHERE user_id = $user_id
#   AND item_id = $item_id;
$self->consume(num => $num);

# アイテムマスタの効果に対応するeffectorクラスを
# インスタンス化する
my $effector = $self->_effector;
# 保持しているMyApp::Model::Userに対して
# アイテム効果を適用させる
my $result = $effector->effect(
    user => $self->user,
    num => $num,
);
return $result;

マスタデータによる効果適用処理の切り替え

前項のコードで示したとおり、効果に応じたクラスをeffect_typeの値に応じて取り出します。この機構についての具体的な実装を示します。

my %effect_type_map = (
    # 1: 体力を回復
    1 => 'MyApp::Model::ItemEffect::RecoverEnergy',
    # そのほかのtypeに応じたpackageのマッピングを羅列する
);
sub _effector {
    my $self = shift;
    return $effect_type_map{$self->effect_type}->new;
}

Perlであれば、上記のようにハッシュにeffect_typeに対応するクラス名を保持しておき、クラス名を取り出してインスタンス化するのが、記述量やわかりやすさの面でバランスが良いです。ほかのプログラミング言語ではswitchやパターンマッチなどの記法を用いると思います。

回復アイテムの効果適用の処理例

さらに、MyApp::Model::ItemEffect::RecoverEnergy内の、実際に体力回復を適用する処理を示します。

sub effect {
    my ($self, %args) = @_;
    my $user = $args{user};
    my $parameter = $args{parameter};
    my $num = $args{num};

    # JSONがO/Rマッパなどのinflate機構を通してハッシュになる前提
    my $energy = $parameter->{energy};
    # UPDATE user
    # SET energy = energy + ($energy * $num)
    # WHERE id = $user_id;
    my $result = $user->add_energy($energy * $num);

    return $result;
}

マスタデータの内容によって、使用するクラスパッケージを切り替えることにより、マスタデータだけでさまざまな効果を持つアイテムを作れました。

マスタデータをテストする

マスタデータで効果を切り替えできると、自由度が高いことの裏返しで、マスタデータに起因するバグが生まれやすくなります。ほかにも、JSONなどの形でスキーマレスなデータをカラムに格納することで、RDBMSRelational Database Management Systemの型に頼った値域などのチェックも弱くなります。

また、プランナーは必ずしもSQLに明るいわけではありません。どのカラムが何の意味を持っていて、どんな値が入るかは、設計者が説明しなければなりません。

そこで、ドキュメントとマスタデータの形式チェックを兼ねた、マスタデータのテストを記述することが考えられます。

マスタデータのカラムの値域をテストする例

MySQLに入ったitemsテーブルのeffect_typeの値域を、素朴にチェックする例を挙げます。

use Test::More;
subtest 'items.effect_typeは1から3までの値を持つ' => sub {
    my $rows = $dbh->selectrow_arrayref(
        'SELECT * FROM items',
        { Slice => {} },
    );
    for my $row (@$rows) {
        cmp_ok $row->{effect_type}, ">=", 1,
            'items.id='.$row->{id};
        cmp_ok $row->{effect_type}, "<=", 3,
            'items.id='.$row->{id};
    }
};
done_testing;

PostgreSQLなどではCHECK制約があるため、こういった定義もDDLに記述すればよいのですが、CHECK制約のないMySQLではテストでカバーします。

また、subtestの説明や、ここで言うcmp_okの説明部分も大事です。筆者のチームでは、このテストをプランナーがCIサーバで実行するため、何が原因でfailしたか理解できるような説明を、普段のテストよりも厚く書いています。

ただ、これではPerlのプログラムそのままで、Perlを読めなければ、failするまで何のテストをしているかを読み解くことができません。Perlプログラマー以外にもわかる、ドキュメントとしてのテストを目指すには難しすぎます。

カラムの値域のチェックを宣言的に書く

そこで、筆者が作成したモジュールであるTest::MasterData::Declareを用いて、宣言的にマスタデータのテストを記述します。

use Test::MasterData::Declare;
master_data {
    load_csv items => "master-data/csv/items.csv",

    table items => "effect_type",
        like_number 1 => 3;
};
done_testing;

ループなどを用いずに、カラムが満たすべき定義を書きます。これにより、一定の規則さえ覚えてしまえば、マスタデータが満たすべき条件を知ることができます。また、failしたときのための説明を書かなくても、なぜfailしたかを出力してくれます。

テストするレコードを条件指定してテストする

別の例として、effect_type=1のときは、effect_parameter.energyがあって、それが1から100までの値であるかを確認するテストを記述してみます。

master_data {
   load_csv items => "master-data/csv/items.csv",

    table items => "effect_parameter",
       if_column effect_type => 1,
       json energy =>
           like_number 1 => 100;
};

カラムの値が10刻みかどうかのテストをする

Perlプログラマー以外にも記述がわかりやすいことは、Perlプログラマー以外でもルールを追加できることにつながります。ゲームでは、マスタデータの値がプログラム上は正しくても、ゲームバランスが崩壊していたり、おもしろくない場合があります。そうなる条件は、パラメータと深く向き合っているプランナーのほうが理解しています。また、プランナーが自分でテスト上のルールを追加できれば、ほかの人にコードでルールを伝えられます。

簡単な例として、体力回復アイテムの回復量は10刻みでないとユーザーにわかりにくく、それ以外の値はミスなので禁止するケースを考えてみます。Test::MasterData::Declareを使用して書くと、

master_data {
   load_csv items => "master-data/csv/items.csv",

    table items => "effect_parameter",
        if_column effect_type => 1,
        json energy =>
            like_number 1 => 100,
            sub { $_[0] % 10 == 0 };
};

となります。

若干Perlコードが入りましたが、簡単なコードなので、プランナーでもコピー&ペーストで追加できるでしょう。

まとめ

ゲームサーバの運用と開発は、大量のアクセス、大量のデータ、大量のユーザーを扱いつつ、安定的に運用することが大事です。これは気合いだけでは実現できません。誰でも同じ結果になりやすく、引き継ぎやすく、理解しやすいしくみを整えていくことが必須です。

ゲーム以外のほかの種類のサービスにも、通じる部分があると思います。本稿を参考に、より良い開発のためのアイデアを、読者のみなさんが思いついて、サービスに適用していただけたらうれしく思います。

さて、次回の執筆者は水音ぴねさんで、テーマは「CPANモジュールの品質を支えるCI技術」です。

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧