Эта страница ещё не переведена — показана английская версия.
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, пока не решено (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.// 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, пока не решено (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.[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, пока не решено (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, пока не решено (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. Здесь эти ограничения не повторяются.