Перейти к основному содержимому
Сервисы вокруг игры

Эта страница ещё не переведена — показана английская версия.

Messaging

Комнаты, группы, игроки: одна модель адресации для чата и уведомлений. Сообщения приходят в беседу, а беседа — это канал, к которому добавлены история, модерация и доставка в обход игрового соединения.

Когда это нужно

  • Игроки (player) общаются — в чате комнаты, каналах гильдии, личных сообщениях — через уже знакомую адресацию: комната, группа, игрок.
  • Игроки не в сети тоже должны узнавать новости: уведомления по шаблонам, в том числе отложенные, приходят пушем в обход игрового соединения.
  • Модерация должна срабатывать до доставки: хук перед отправкой фильтрует или отклоняет сообщение, а заглушение и блокировку платформа применяет везде.
  • Вернувшимся игрокам нужно наверстать пропущенное: при следующем запуске History(take: 50) постранично загружает беседу (conversation).
  • Не нужен, если передаётся игровое состояние, а не переписка. Его и так рассылают синхронизируемые поля из Data & Subscriptions и каналы Core.

Кто что делает

АкторЧто делает на этой странице
playerотправляет и получает сообщения, читает историю, заглушает и блокирует
moderatorфильтрует и скрывает сообщения, банит слова
backend-serviceотправляет уведомления по шаблонам или планирует их отправку

Коротко

Один Send на каждый вид адресата — комната, гильдия, личка, — а ещё подписка и история
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);
// conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(roomId), 'gg!');
await playserv.messaging.send(Conversation.group(guildId), rally);
await playserv.messaging.send(Conversation.direct(friendId), 're?');

playserv.messaging.subscribe(Conversation.group(guildId), (msg) => chat.add(msg));
const history = await playserv.messaging.history(Conversation.room(roomId), { take: 50 });
# conversations map to the addressing you already have
await playserv.messaging.send(Conversation.room(room_id), "gg!")
await playserv.messaging.send(Conversation.group(guild_id), rally)
await playserv.messaging.send(Conversation.direct(friend_id), "re?")

playserv.messaging.subscribe(Conversation.group(guild_id), lambda msg: chat.add(msg))
history = await playserv.messaging.history(Conversation.room(room_id), take=50)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

// conversations map to the addressing you already have
Client->Messaging->Conversations->Get(FPSConversation::Room(RoomId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSConversation* RoomChat = Result.Value();
        RoomChat->Send->Text({ TEXT("gg!") });

        // history pages under the same node that carries the messages
        RoomChat->Messages->Select().Page(50).Then(
            TPSOnResult<TPSPage<FPSMessage>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSMessage>>& History)
            {
                if (!History.HasValue()) { return; }
                Chat->Show(History.Value().Rows);
            }));
    }));

// group and direct targets resolve the same way
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId), OnConversation);
Client->Messaging->Conversations->Get(FPSConversation::Direct(FriendId), OnConversation);

// live messages: one handler, every target
TPSSubscription GuildFeed = Guild->Subscribe([this](const FPSMessage& Message) { Chat->Add(Message); });
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — работает в Unity без изменений, со сгенерированными типами
// conversations map to the addressing you already have
await playserv.Messaging.Send(Conversation.Room(roomId), "gg!");
await playserv.Messaging.Send(Conversation.Group(guildId), rally);
await playserv.Messaging.Send(Conversation.Direct(friendId), "re?");

playserv.Messaging.Subscribe(Conversation.Group(guildId), msg => Chat.Add(msg));
var history = await playserv.Messaging.History(Conversation.Room(roomId), take: 50);

Структурированное сообщение — это объявленное событие (event). Беседа передаёт его по имени, и в месте вызова не нужно создавать класс с полезной нагрузкой:

RallyCall объявлен один раз, и беседа гильдии отправляет его по имени
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");
@Message('rallyCall')
export class RallyCall {
  at!: Vector3;
  note = '';
}

const guild = playserv.group(guildId).conversation;
await guild.send.rallyCall({ at: northGate, note: 'push now' });
@message("rallyCall")
class RallyCall:
    at: Vector3
    note: str = ""

guild = playserv.group(guild_id).conversation
await guild.send.rally_call(at=north_gate, note="push now")
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

USTRUCT(PSMessage = (Name = "rallyCall"))
struct FRallyCall
{
    GENERATED_BODY()

    UPROPERTY() FVector At;
    UPROPERTY() FString Note;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI

// the group's conversation is an address you resolve, then send into
Client->Messaging->Conversations->Get(FPSConversation::Group(GuildId),
    TPSOnResult<FPSConversation*>::CreateWeakLambda(this, [this](const TPSResult<FPSConversation*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Result.Value()->Send->RallyCall({ NorthGate, TEXT("push now") });
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — то же объявление и тот же вызов компилируются в Unity на базовой версии 2021.3
[Message("rallyCall")]
public class RallyCall
{
    public Vector3 At;
    public string Note = "";
}

var guild = PlayServ.Group(guildId).Conversation;
await guild.Send.RallyCall(at: northGate, note: "push now");

Объявленное сообщение приходит типизированным в той же подписке (subscription): клиент, который знает RallyCall, получает поля, а не сырые байты.

Уведомления доставляются в обход игрового соединения, строятся по шаблонам и могут уходить по расписанию. Отправить их можно только с полномочиями fn или adm, но никогда из сессии игрока:

Уведомление raid-starts по шаблону: его отправляет облачная функция, а доставляется оно в обход игрового соединения
// cloud function — Notify needs fn/adm authority
await PlayServ.Messaging.Notify(playerId, Template.Named("raid-starts"),
                                args: new { at = start });
// cloud function — notify needs fn/adm authority
await playserv.messaging.notify(playerId, Template.named('raid-starts'),
  { args: { at: start } });
# cloud function — notify needs fn/adm authority
await playserv.messaging.notify(player_id, Template.named("raid-starts"),
                                args={"at": start})
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

Этот вызов делает другой актор

Вызов в Unreal есть. Но для отправки уведомления нужны полномочия fn/adm, поэтому в сессии игрока платформа его отклонит, из какого бы биндинга его ни сделали. Unreal получает уже доставленное уведомление. См. Access & Roles.

Этот вызов делает другой актор

Вызов в Unity есть. Но для отправки уведомления нужны полномочия fn/adm, поэтому в сессии игрока платформа его отклонит, из какого бы биндинга его ни сделали. Unity получает уже доставленное уведомление. См. Access & Roles.

Модерация сделана на хуках, с тем же контрактом, что и везде:

Хук перед отправкой: мат отклоняется раньше, чем сообщение куда-либо попадёт
[Before(Messaging.Send)]
public static Verdict Filter(OutgoingMessage m) =>
    Profanity.Hits(m.Text) ? Hook.Reject("filtered") : Hook.Continue(m);
export const filter = before(Messaging.send, (m: OutgoingMessage) =>
  Profanity.hits(m.text) ? Hook.reject('filtered') : Hook.continue(m));
@before(messaging.send)
def filter_message(m: OutgoingMessage) -> Verdict:
    return Hook.reject("filtered") if profanity.hits(m.text) else Hook.continue_(m)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unreal подпишется на события, которые он порождает. В планах — функции, написанные прямо в движке: графы Blueprint и C++, который будет транслироваться в скрипт, проверяемый платформой. Их будут анализировать и загружать так же, как любые объявления, а выполняться они по-прежнему будут на платформе.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unity подпишется на события, которые он порождает.

Модель

Что объявляет тип беседы.

ОбъявлениеЧто это
группаеё состав: участник — это актор, в точности как в группах
привязка к времени жизнинеобязательная, к времени жизни другой сущности (entity): так чат комнаты исчезает вместе с комнатой

Где проходит граница между конвертом и полезной нагрузкой.

ЧастьЧья она
конвертплатформы: автор, беседа и момент по объявленным часам
полезная нагрузкастудии: объявляется как тип сообщения с типизированными полями и получает собственный интерфейс отправки, а не передаётся нетипизированным мешком данных

Три вещи, которые есть у этого модуля, но нет у простого события: порядок внутри беседы, срок хранения и предмет модерации. Ничего из этого в Events нет, поэтому чат — не «событие с историей».

Три машины состояний.

МашинаСостояния
беседаcreated → active → closed
сообщениеsent → published | rejected by the filter, а затем правка или удаление, которые всегда видны
уведомлениеcreated → queued → delivered | expired

Что верно для любого сообщения.

ВсегдаЧто это
порядок внутри беседыстабилен и объявлен. Порядок между беседами не гарантируется
правка и удалениевсегда видны: сообщение никогда не исчезает молча, поэтому история на клиенте не может незаметно разойтись с серверной
история— это сами сообщения. У неё объявленный срок хранения, а читают её постранично по курсору, начиная с заданной позиции
хранение дольше окна жалобсрок хранения не короче времени, отведённого на разбор жалобы. Жалоба приходит после сообщения, и если сообщения уже нет, разбирать нечего
состояние прочтения— это позиция, а не флаг: одна позиция на актора в каждой беседе. Отметка о прочтении монотонна: позиция никогда не уменьшается, поэтому повторный вызов не откатит прогресс. Число непрочитанных выводится из этой позиции, а не хранится отдельным счётчиком
отправкаидемпотентна по ключу: два вызова — это две реплики в разговоре, и только ключ делает повтор безопасным
блокировка— предикат доставки, а не запрет на отправку. Отправителю ничего не сообщают, потому что отказ выдал бы блокировку. Само состояние хранится в Social
нагрузку собирает отправительплатформа не читает данные получателя, чтобы подставить их в ваш текст. Локаль получателя может прийти в точку расширения как объявленный контекст, поэтому подстановка и перевод — работа хука: только он знает и получателя, и его локаль
маршрут доставкине входит в контракт: пуш, уведомление внутри приложения или что-то ещё — это решение маршрутизации, а не обещание
доставкавидна в объявленных пределах: queued — всегда, а дальше — насколько может сообщить маршрут

Каждая точка расширения указывает, какой тип она передаёт хуку: до публикации — исходящее сообщение, после — опубликованное. Фильтр может исправить полученное содержимое (замаскировать слово — тоже исправление), но не отправителя и не беседу.

Ошибки

  • Несуществующая или скрытая беседа, как и актор, который в ней не участвует, получают not found: так отказ никогда не выдаст беседу, в которой вас нет.
  • Закрытая беседа — конфликт.
  • Нет разрешения (permission) писать в беседы этого типа — ответ forbidden, повторять бессмысленно.
  • Отклонение фильтром — это вердикт, а не отказ. Вызов выполнен, содержимое рассмотрено, решение отрицательное, а причина — объявленное значение. Поэтому такой ответ можно отличить от отказа по разрешениям, и что делать дальше, зависит от причины.
  • Если фильтр недоступен, ответ — unavailable, и стоит повторять с нарастающей задержкой. Но за это время ничего не публикуется.
  • Тип сообщения, не объявленный для этой беседы, и слишком большое сообщение — отказы валидации. Содержимое никогда не обрезается молча.
  • При превышении частоты отправки ответ относится к категории ограничения частоты и содержит срок, после которого можно повторить.
  • Правка чужого сообщения — ответ forbidden.
  • Уведомление с истёкшим сроком — конфликт: отправьте новое.

Ограничения

Конкретные числа появятся вместе с главой об ограничениях платформы, а поведение на каждой границе уже определено.

  • Размер сообщения, число и размер вложений: отправка отклоняется как ошибка валидации, но никогда не обрезается. Сами файлы описаны на странице Files & UGC.
  • Частота отправки от актора: ограничение частоты со сроком повтора.
  • Глубина истории: по истечении срока сообщение вытесняется из хранения с событием, а не исчезает тихо.
  • Беседы на актора: вступление в ещё одну отклоняется как конфликт.
  • Уведомления в очереди на актора: новое отклоняется, а вытеснять старые запрещено — молча выброшенное уведомление выглядело бы как неотправленное.
  • Срок действия уведомления: expired, с событием.
  • Число участников беседы ограничивает Groups, а число блокировок на актора — Social. Здесь эти ограничения не повторяются.