跳到主要内容
游戏周边服务

本页尚未翻译,当前显示英文版本。

Messaging

房间、群组、玩家:聊天和通知共用一套寻址模型。 消息发送到对话中;对话就是频道,再加上历史记录、内容审核和带外投递。

何时使用

  • 玩家基于你已有的寻址方式聊天(房间聊天、公会频道、私信):房间(room)、群组、玩家。
  • 离线玩家也必须收到消息:基于模板、可定时发送的通知通过推送通知带外投递。
  • 内容审核必须在投递之前执行:发送前的 hook 负责过滤或拒绝,禁言和屏蔽由平台在所有地方强制执行。
  • 回归的玩家需要补看消息:下次启动时,History(take: 50) 分页读取对话。
  • 无需使用:如果 payload 是游戏状态,而不是对话。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),对话按名称承载它,调用处无需构造 payload 类:

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");

声明过的消息会以带类型的形式出现在同一个订阅中,所以知道 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。

内容审核以 hook 实现,契约与其他地方完全相同:

发送前的 hook:脏话在落地之前就会拒绝
[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),确定写法后会补上示例。

在引擎之外运行

hook 是云函数(cloud function):它在平台上执行,而不是在引擎中。用 C#、TypeScript 或 Python 编写,Unreal 订阅由此产生的事件。引擎内编写的函数已在计划中:Blueprint 图表,以及 C++(转译为平台验证的脚本目标)。两者都会像其他声明一样经过分析和推送,执行仍在平台上。

在引擎之外运行

hook 是云函数:它在平台上执行,而不是在引擎中。用 C#、TypeScript 或 Python 编写,Unity 订阅由此产生的事件。

模型

对话类型声明的内容。

声明项含义
群组对话的名单(roster)——参与者就是主体,与群组中完全一致
绑定到生命周期可选,绑定到另一个实体的生命周期,这样房间聊天会随房间一起消失

信封与 payload 的分界线。

部分归属
信封归平台:作者、对话、按声明的时钟记录的时刻
payload归工作室,声明为带类型字段的消息类型,并以独立的发送接口出现,而不是一个无类型的数据包

本模块拥有而普通事件没有的三样东西: 对话内的顺序、保留期,以及内容审核的对象。事件中都没有这三样,所以聊天不是“带历史记录的事件”。

三个状态机。

对象状态
对话created → active → closed
消息sent → published | rejected by the filter,之后可编辑或删除,且可观察
通知created → queued → delivered | expired

每条消息都成立的规则。

始终成立含义
对话内的顺序稳定且已声明。不同对话之间的顺序不作保证
编辑和删除可观察:消息从不静默消失,所以客户端的历史记录不会在无人察觉时与服务端产生分歧
历史记录就是消息本身:有声明的保留期(retention),从某个位置开始按游标分页读取
保留期长于投诉处理窗口保留期不短于处理投诉所允许的时间:投诉在消息之后到达,而已经不存在的消息无从处理
已读状态是一个位置,而不是标志:每个主体在每个对话中有一个位置,标记已读是单调的——位置从不后退,所以重复调用无法撤销进度。未读数是由该位置推导出来的,而不是单独的计数器
发送按键幂等:两次调用就是两句对话,所以正是幂等键(idempotency key)让重试变得安全
屏蔽是投递谓词,而不是拒绝发送:发送方不会收到通知,因为拒绝会暴露屏蔽。屏蔽状态本身在 Social 中
由发送方组装 payload平台不会读取接收方的数据来填充你的文本。接收方的语言区域可以是一个声明的上下文 claim,随请求传到扩展点,所以替换和翻译是 hook 的工作——只有它同时知道接收方和对方的语言区域
投递路径不属于契约:推送通知、应用内还是其他方式,是路由决策,而不是承诺
投递在声明的范围内可观察:“queued”始终可见;更进一步的状态取决于投递路径能报告多少

每个扩展点都声明它交给 hook 的类型:发布前是待发出的消息,发布后是已发布的消息。过滤器可以修正交给它的内容——遮蔽某个词就是一种修正——但绝不能修改发送方或对话。

错误

  • 不存在或已隐藏的对话,以及不是参与者的主体,都返回 not found——所以拒绝绝不会暴露你不在其中的对话。
  • 已关闭的对话属于冲突。
  • 没有在此类对话中发言的权限返回 forbidden,重试没有意义。
  • 过滤器拒绝是裁决,而不是拒绝请求。 调用已经执行,内容已经过审查,结论是否定的,原因是一个声明的值——所以它与权限拒绝可以区分,下一步怎么做也取决于原因。
  • 过滤器不可用时返回 unavailable,值得以退避方式重试——但在此期间没有任何消息发布。
  • 该对话未声明的消息类型,以及超出大小的消息,属于校验拒绝;内容从不静默截断。
  • 超出发送频率时返回限流类别的错误,并附带截止时间。
  • 编辑他人的消息返回 forbidden。
  • 已过期的通知属于冲突:发送一条新的。

限制

具体数值会随平台限制一章给出;触及每个上限时的行为现在已经确定。

  • 消息大小,以及附件数量和大小:发送以校验失败拒绝,从不截断。文件本身见 Files & UGC。
  • 每个主体的发送频率:带截止时间的限流。
  • 历史记录深度:超过保留期的消息会移出保留范围并发出事件,而不是悄无声息地消失。
  • 每个主体的对话数:再加入一个对话时,平台以冲突拒绝。
  • 每个主体排队的通知数:拒绝新通知,而且禁止淘汰:被静默丢弃的通知看起来和从未发送过一样。
  • 通知的过期时间:变为 expired,并发出事件。
  • 对话参与者数是群组的限制,每个主体的屏蔽数是 Social 的限制——这里都不再重复。