GS2-Guild

ギルド機能

複数のプレイヤーでチームを作成し、一緒になんらかの目標に向かって活動するための機能がギルド機能です。 GS2-Guild はギルドメンバーの管理と、ギルドメンバーが実行できる権限管理を行うためのマイクロサービスです。

GS2-Guild において「ギルド」は一人のプレイヤーとして処理され、ギルドユーザーと呼びます。 ギルドメンバーはギルドユーザーとして情報を取得・更新可能なフェデレーションを行うことで、ギルドメンバーが共通のプロパティを操作可能な状態を実現します。

例えば プレイヤーA プレイヤーB が所属している ギルドA があったと仮定します。 プレイヤーレベルスタミナ といった一般的な情報が プレイヤーAプレイヤーB に紐づいて管理されることは容易に想像できるでしょう。 これと同じように ギルドA に紐づいて ギルドレベル のようなプロパティを紐づけて管理することも容易に想像できるでしょう。

GS2-Guild の思想の特徴的な部分として プレイヤーA プレイヤーBギルドA の明確な区別はなく一律 ユーザー と捉えて処理されることにあります。 そのため、ギルドが持つプロパティには GS2 のあらゆるユーザーデータを扱うマイクロサービスを使用して管理できます。 たとえば、ギルドに GS2-Experience のランク・経験値を持たせることもできますし、GS2-Stamina のスタミナを持たせることもできますし、GS2-SkillTree のスキルツリーを持たせることもできます。

そして、GS2-Guild ではギルドメンバーを管理するとともに、ギルドメンバーが ギルドユーザー としてGS2のAPIを呼び出せるアクセストークンを発行する機能があります。 その際にギルドユーザーとしてどのAPIを呼び出せるのかをより細かく制御するためのアクセス権限制御機能があります。 この機能を利用すれば ギルドマスター の役職をもつプレイヤーのみが、ギルドユーザーとして GS2-Showcase の商品を購入できるような機能を実現できます。

ギルドメンバー

ギルドに所属しているプレイヤーを指します

ロール

ギルドに所属しているプレイヤーの役職に相当するエンティティで、実行可能なAPIの種類を定義します。 1つのギルドモデルに最大10種類のロールを定義できます。

権限設定

ロールには GS2-Identifier のポリシードキュメントを定義できます。 ギルドユーザーに変わる前の権限 && ロールの権限 の範囲でAPIを呼び出すことができます。

つまり、ロールに強力な権限を設定したとしても、元々のギルドユーザーとしてアクセスしている時に呼び出せなかったAPIを呼び出せるようにはなりません。

ポリシードキュメントの例

ギルドマスターに相当する最小のポリシー

{
  "Version": "2016-04-01",
  "Statements": [
    {
      "Effect": "Allow",
      "Actions": [
        "Gs2Guild:Describe*",
        "Gs2Guild:Get*",
        "Gs2Guild:AcceptRequest",
        "Gs2Guild:RejectRequest",
        "Gs2Guild:DeleteMember",
        "Gs2Guild:DescribeIgnoreUsers",
        "Gs2Guild:AddIgnoreUser",
        "Gs2Guild:GetIgnoreUser",
        "Gs2Guild:DeleteIgnoreUser",
        "Gs2Gateway:SetUserId"
      ],
      "Resources": ["*"]
    }
  ]
}

ギルドユーザーに相当する最小のポリシー

{
  "Version": "2016-04-01",
  "Statements": [
    {
      "Effect": "Allow",
      "Actions": [
        "Gs2Guild:Describe*",
        "Gs2Guild:Get*",
        "Gs2Guild:PromoteSeniorMember",
        "Gs2Gateway:SetUserId"
      ],
      "Resources": ["*"]
    }
  ]
}

実践的な例

権限は GS2-Identifier のポリシードキュメントフォーマットを用いて定義します。 具体的な例として、ギルドの管理権限とギルドユーザーの GS2-Experience のAPIを呼び出す権限を付与するとしましょう。

{
  "Version": "2016-04-01",
  "Statements": [
    {
      "Effect": "Allow",
      "Actions": [
        "Gs2Guild:Describe*",
        "Gs2Guild:Get*",
        "Gs2Guild:AcceptRequest",
        "Gs2Guild:RejectRequest",
        "Gs2Guild:DeleteMember",
        "Gs2Guild:DescribeIgnoreUsers",
        "Gs2Guild:AddIgnoreUser",
        "Gs2Guild:GetIgnoreUser",
        "Gs2Guild:DeleteIgnoreUser",
        "Gs2Gateway:SetUserId",
        "Gs2Experience:*"
      ],
      "Resources": ["*"]
    }
  ]
}

前半の 「Gs2Guild」で始まる部分がギルドユーザーとして実行可能なAPIを宣言する部分で、メンバー管理に必要なAPIが指定されています。 「Gs2Gateway:SetUserId」にはギルドユーザーに関する通知を受け取れるようにする権限設定です。 この宣言がないと通知を受け取ることができず、ギルドへの参加申請が届いたことや、ギルドユーザーに関するプロパティの変化をアプリが知ることができなくなります。 最後の「Gs2Experience:*」が GS2-Experience の全てのAPIを呼び出す権限を付与する部分です。

さて、ここで 「Gs2Experience:*」と書いてしまうと、「Gs2Experience:AddExperienceByUserId」 のような任意のプレイヤーに任意の経験値を付与するAPIを呼び出せるようになるのでは?と不安に思うかもしれません。 しかし、ユーザーフェデレーションをする場合、フェデレーションする前のアクセストークンが持つ権限より強い権限を得ることはできません。 そのため、一般的なゲームプレイヤーが持つであろう「ApplicationAccess」権限では元々「Gs2Experience:AddExperienceByUserId」を呼び出すことはできませんので、ロールのポリシードキュメントに全てのAPIを呼び出せるように記述したとしても、「Gs2Experience:AddExperienceByUserId」を呼び出せるようにはなりません。

GS2-Showcase でギルドマスターだけがギルドユーザーとして商品を買えるようにしたければ、ギルドマスターのロールにだけ「Gs2Showcase:Buy」をつけるといった対応をすることで細やかな権限管理ができます。

カスタムロール

ギルド固有のロールです。 プレイヤーに権限の組み合わせを自由に定義させたい場合に使用できます。

参加人数

ギルドには参加人数の制限を設定することができます。 参加人数の上限はギルド単位で引き上げることができ、ギルドレベルの上昇とともに参加人数の上限を引き上げるような実装が可能です。

参加方針

ギルドごとに参加方針を設定できます。 「自由参加」と「承認制」を設定できます。

自由参加

参加リクエストを出して、ギルドメンバーに空きがある場合は即時参加が可能です。

承認制

参加リクエストを出してもすぐにはギルドメンバーにはなれません。 ギルドメンバーの中で承認権限をもつプレイヤーに承認してもらうことでギルドメンバーになることができます。

ギルドの検索

ギルドの表示名

ギルドの表示名として登録した文字列の部分一致で検索することができます。

属性値

ギルドには最大5種類の属性値を設定可能です。 属性値は整数値のみで、ギルドの検索条件に使用することができます。

ギルドを検索する際には検索条件として、各属性値に対して最大10種の値で絞り込むことができます。 属性値を大小判定で絞り込むことはできません。

検索条件に複数の属性値を指定した場合は、AND 判定で検索されます。OR で検索する方法はありません。

メンバーが最大に達しているギルドを検索結果に含むか

真偽値で指定できます。true を指定するとメンバーが最大値に達しているギルドも検索結果に含みます。 ちなみに、メンバーが最大値に達しているギルドに対しても参加リクエストを出すことは可能です。

検索対象

検索対象には過去24時間以内に更新のあったギルドのみが含まれます。 これはアクティブではないギルドを対象から除外しつつ、検索に必要なコストを最小にする目的でこのような仕様になっています。

ギルドマスターは特に変更点がなくても1日一回はギルド情報を更新するような実装にしてください。

なお、更新にはメンバーの増減やロールの割り当ても含まれます。

ギルド参加のクールダウン

ギルド参加のクールダウンのパラメーターを設定すると、どこかのギルドから離脱した後クールダウンに設定した時間(分単位)が経過するまで他のギルドへの参加リクエストを出すことができません。

複数ギルドへの参加

プレイヤーは複数のギルドに同時に参加できます。 参加できるギルドの数に制約を設けたい場合は、アプリケーションで制御してください。

ギルドマスターが引退した時の緊急措置

唯一のギルドマスターがなんの前触れもなく引退してしまった場合、ギルドの維持が困難になります。 このような状態に陥った時の緊急措置を用意しています。

ギルドマスターの最終アクティブ時間の取得

GetLastGuildMasterActivity 関数を利用すると、ギルドマスターロールを所有しているプレイヤーが最後に Assume した日時を取得できます。 この日時を確認することでギルドマスターロールを持つ「誰」が「いつ」アクセスしたのが最後のアクセスかを判断することができます。

緊急措置の有効化日数

GuildModel には緊急措置を有効化するために待つ必要がある日数を定義できます。 ギルドマスターの最終アクティブ時間から、ここで定めた日数が経過することで緊急措置を実行できるようになります。

緊急措置の実行

緊急措置の実行は PromoteSeniorMember を呼び出すことで実行できます。 このAPIを呼び出すと、ギルドメンバーの中で最も参加日時が古いプレイヤーを新しいギルドマスターに昇格させます。 この時、最終アクティブ時間も昇格したプレイヤーがアクティブになったとして更新されます。つまり、この古参メンバーも長期間アクティブではない場合は追加で一定期間を待つ必要があります。

ギルドマスター交代の通知

GS2-Guild 自体にはギルドマスター交代に関する通知を行う仕組みはありません。 ただし、ギルドメンバーのロールが更新された時に実行されるスクリプト は定義可能ですので、スクリプトからギルドチャットに書き込むなどの追加の処理をすることで、ギルドメンバーがギルドマスター交代に関する情報を知ることができます。

実装例

ギルドを作成

    var result = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).CreateGuildAsync(
        guildModelName: "guild-model-0001",
        displayName: "My Guild",
        joinPolicy: "anybody",
        attribute1: 1,
        attribute2: null,
        attribute3: null,
        attribute4: null,
        attribute5: null,
        customRoles: null,
        guildMemberDefaultRole: null
    );
    var item = await result.ModelAsync();
    const auto Future = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        GameSession
    )->CreateGuild(
        "guild-model-0001", // guildModelName
        "My Guild", // displayName
        "anybody", // joinPolicy
        1, // attribute1
        TOptinal<int32>(), // attribute2
        TOptinal<int32>(), // attribute3
        TOptinal<int32>(), // attribute4
        TOptinal<int32>(), // attribute5
        nullptr, // customRoles
        TOptinal<FString>() // guildMemberDefaultRole
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError())
    {
        return false;
    }

    // obtain changed values / result values
    const auto Future2 = Future->GetTask().Result()->Model();
    Future2->StartSynchronousTask();
    if (Future2->GetTask().IsError())
    {
        return Future2->GetTask().Error();
    }
    const auto Result = Future2->GetTask().Result();

ギルドを検索

    var items = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).SearchGuildsAsync(
        guildModelName: "guild-model-0001",
        displayName: "My Guild",
        attributes1: new int[] { 0, 1 },
        attributes2: null,
        attributes3: null,
        attributes4: null,
        attributes5: null,
        joinPolicies: null,
        includeFullMembersGuild = null
    ).ToListAsync();
    const auto It = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        GameSession
    )->SearchGuilds(
        "guild-model-0001", // guildModelName
        "My Guild", // displayName
        MakeShared<TArray<int32>>([]{
            TArray<int32> v;
            v.Add(0);
            v.Add(1);
            return v;
        }()), // attributes1
        TSharedPtr<TArray<int32>>(), // attributes2
        TSharedPtr<TArray<int32>>(), // attributes3
        TSharedPtr<TArray<int32>>(), // attributes4
        TSharedPtr<TArray<int32>>(), // attributes5
        TSharedPtr<TArray<FString>>(), // joinPolicies
        TOptional<bool>(true) // includeFullMembersGuild
    );
    TArray<Gs2::UE5::Guild::Model::FEzGuildPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

ギルドに参加リクエストを送信

ギルドの参加方針が「自由参加」の場合は直ちにギルドメンバーになります

    var result = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).SendRequestAsync(
        guildModelName: "guild-0002",
        targetGuildName: "guild-0002"
    );
    const auto Future = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        GameSession
    )->SendRequest(
        "guild-0002", // guildModelName
        "guild-0002" // targetGuildName
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError())
    {
        return false;
    }

送信した参加リクエストの一覧を取得

    var items = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).SendRequestsAsync(
        guildModelName: "guild-0002"
    ).ToListAsync();
    const auto It = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        GameSession
    )->SendRequests(
        "guild-0002" // guildModelName
    );
    TArray<Gs2::UE5::Guild::Model::FEzSendMemberRequestPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

参加リクエストを取り下げ

    var result = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).CancelRequestAsync(
        guildModelName: "guild-0002",
        targetGuildName: "guild-0002"
    );
    const auto Future = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        GameSession
    )->CancelRequest(
        "guild-0002", // guildModelName
        "guild-0002" // targetGuildName
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError())
    {
        return false;
    }
    const auto Result = Future->GetTask().Result();

ギルドユーザーとしてアクセスするためのゲームセッションを取得

    var guildGameSession = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).AssumeAsync(
        guildModelName: "guild-model-0001",
        guildName: "guild-0001"
    );
    const auto Future = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        GameSession
    )->Assume(
        "guild-model-0001", // guildModelName
        "guild-0001" // guildName
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError())
    {
        return false;
    }
    const auto GuildGameSession = Future->GetTask().Result();

受信した参加リクエストの一覧を取得

    var domain = gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).GuildGameSession(
        guildModelName: "guild-0001",
        guildGameSession: guildGameSession
    );
    var items = await domain.ReceiveRequestsAsync(
    ).ToListAsync();
    const auto It = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->GuildGameSession(
        "guild-0001", // guildModelName
        GuildGameSession // guildGameSession
    )->ReceiveRequests(
    );
    TArray<Gs2::UE5::Guild::Model::FEzReceiveMemberRequestPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }

参加リクエストを承認

    var result = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    )->GuildGameSession(
        "guild-0001", // guildModelName
        GuildGameSession // guildGameSession
    ).ReceiveMemberRequest(
        fromUserId: "user-0001"
    ).AcceptRequestAsync(
    );
    const auto Future = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->GuildGameSession(
        "guild-0001", // guildModelName
        GuildGameSession // guildGameSession
    )->ReceiveMemberRequest(
        "user-0001" // fromUserId
    )->AcceptRequest(
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError())
    {
        return false;
    }
    const auto Result = Future->GetTask().Result();

参加リクエストを否認

    var result = await gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    )->GuildGameSession(
        "guild-0001", // guildModelName
        GuildGameSession // guildGameSession
    ).ReceiveMemberRequest(
        fromUserId: "user-0001"
    ).RejectRequestAsync(
    );
    const auto Future = Gs2->Guild->Namespace(
        "namespace-0001" // namespaceName
    )->GuildGameSession(
        "guild-0001", // guildModelName
        GuildGameSession // guildGameSession
    )->ReceiveMemberRequest(
        "user-0001" // fromUserId
    )->RejectRequest(
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError())
    {
        return false;
    }
    const auto Result = Future->GetTask().Result();

詳細なリファレンス