Esta página ainda não foi traduzida: mostrando a versão em inglês.
Messaging
Salas, grupos, jogadores: um só modelo de endereçamento para chat e notificações. As mensagens chegam numa conversa; conversas são canais com histórico, moderação e entrega fora de banda por cima.
Quando usar
- Os jogadores conversam (chat da sala, canais da guilda, mensagens diretas) usando o endereçamento que você já tem: sala (room), grupo, jogador.
- Jogadores offline também precisam ser avisados: notificações com modelo e agendáveis chegam fora de banda (out-of-band), como notificações push.
- A moderação precisa rodar antes da entrega: um hook pré-envio filtra ou rejeita, e a plataforma aplica o silenciamento e o bloqueio em todo lugar.
- Quem volta ao jogo precisa se atualizar:
History(take: 50)pagina a conversa na próxima vez que o jogo abre. - Dispensável quando o payload é estado do jogo, não conversa. Os campos sincronizados de Data & Subscriptions e os canais do Core já distribuem isso.
Quem faz o quê
| Ator | O que faz nesta página |
|---|---|
player | envia e recebe mensagens; lê o histórico; silencia ou bloqueia |
moderator | filtra e oculta mensagens, bane termos |
backend-service | envia ou agenda notificações com modelo |
Resumo
Send por destino — sala, guilda, mensagem direta — além de assinatura e histórico// 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)Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
// 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);Uma mensagem estruturada é um evento declarado, e a conversa a transporta pelo nome, sem nenhuma classe de payload para montar no ponto de chamada:
RallyCall declarado uma vez; a conversa da guilda o envia pelo nome[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")Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
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");A mensagem declarada chega tipada na mesma assinatura (subscription), então um cliente que conhece RallyCall recebe campos, e não um blob.
As notificações são fora de banda, têm modelo e podem ser agendadas; elas saem com autoridade fn ou adm, nunca de uma sessão de jogador:
raid-starts com modelo, enviada de uma cloud function e entregue fora de banda// 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})Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
A chamada existe na Unreal. Enviar uma notificação exige autoridade fn/adm, então a plataforma a recusa numa sessão de jogador, seja qual for o binding que faz a chamada; a Unreal recebe a notificação entregue. Veja Access & Roles.
A chamada existe na Unity. Enviar uma notificação exige autoridade fn/adm, então a plataforma a recusa numa sessão de jogador, seja qual for o binding que faz a chamada; a Unity recebe a notificação entregue. Veja Access & Roles.
A moderação é feita com hooks, com o mesmo contrato de qualquer outro lugar:
[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)Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.
Um hook é uma cloud function: roda na plataforma, não na engine. Escreva-o em C#, TypeScript ou Python — a Unreal assina os eventos que ele gera. Estão previstas funções escritas na própria engine: grafos de Blueprint e C++ (traduzido para um script que a plataforma valida). As duas formas são analisadas e publicadas como qualquer declaração; a execução continua na plataforma.
Um hook é uma cloud function: roda na plataforma, não na engine. Escreva-o em C#, TypeScript ou Python — a Unity assina os eventos que ele gera.
O modelo
O que um tipo de conversa declara.
| Declara | O que é |
|---|---|
o grupo | a lista de participantes — um participante é um ator, exatamente como em Groups |
vínculo a um ciclo de vida | opcionalmente ao de outra entidade, para que o chat de uma sala desapareça junto com a sala |
Onde passa a linha entre o envelope e o payload.
| Parte | De quem é |
|---|---|
envelope | da plataforma: o autor, a conversa, o momento segundo o relógio declarado |
payload | do estúdio: declarado como um tipo de mensagem com campos tipados, e aparece como uma interface de envio própria, e não como um saco de dados sem tipo |
Três coisas que este módulo tem e um evento simples não tem: a ordem dentro de uma conversa, o período de retenção e o objeto da moderação. Nada disso existe nos eventos, e é por isso que chat não é “um evento com histórico”.
As três máquinas de estados.
| De | Estados |
|---|---|
| uma conversa | created → active → closed |
| uma mensagem | sent → published | rejected by the filter, e depois editada ou excluída, de forma observável |
| uma notificação | created → queued → delivered | expired |
O que vale para toda mensagem.
| Sempre | O que é |
|---|---|
a ordem dentro de uma conversa | é estável e declarada. A ordem entre conversas não é garantida |
editar e excluir | são ações observáveis: uma mensagem nunca some em silêncio, então o histórico de um cliente não diverge do histórico do servidor sem que ninguém perceba |
o histórico | são as próprias mensagens: com um período de retenção declarado, lidas em páginas, por cursor, a partir de uma posição |
a retenção dura mais que o prazo de denúncia | o período não é menor que o tempo previsto para tratar uma denúncia: a denúncia chega depois da mensagem, e uma mensagem que não existe mais não deixa nada para tratar |
o estado de leitura | é uma posição, não uma flag: uma posição por ator em cada conversa, e marcar como lida é monotônico — a posição nunca volta, então repetir a chamada não desfaz o progresso. A contagem de não lidas é derivada dessa posição, e não um contador próprio |
o envio | é idempotente por chave: duas chamadas são duas falas no diálogo, então é a chave que torna segura uma nova tentativa |
o bloqueio | é um predicado de entrega, não uma recusa de envio: o remetente não fica sabendo, porque uma recusa revelaria o bloqueio. O estado em si fica no módulo Social |
o remetente monta o payload | a plataforma não lê os dados do destinatário para preencher o seu texto. O idioma do destinatário pode ser uma claim de contexto declarada que chega até o ponto de extensão; por isso, a substituição e a tradução são trabalho do hook — o único lugar que conhece tanto o destinatário quanto o idioma dele |
o caminho da entrega | não faz parte do contrato: push, in-app ou outro meio é uma decisão de roteamento, não uma promessa |
a entrega | é observável dentro de limites declarados: “na fila”, sempre; qualquer coisa além disso, até onde o caminho consegue informar |
Cada ponto de extensão indica o tipo que entrega ao hook: antes da publicação, a mensagem de saída; depois, a mensagem publicada. Um filtro pode corrigir o conteúdo que recebeu — mascarar uma palavra é uma correção —, mas nunca o remetente nem a conversa.
Erros
- Uma conversa que não existe ou está oculta e um ator que não é participante recebem not found — assim, uma recusa nunca revela uma conversa da qual você não faz parte.
- Uma conversa encerrada é um conflito.
- Sem permissão para escrever neste tipo de conversa, a resposta é forbidden, e não adianta repetir.
- A rejeição pelo filtro é um veredito, não uma recusa. A chamada foi executada, o conteúdo foi avaliado, a decisão é negativa e o motivo é um valor declarado. Por isso ela se distingue de uma recusa por falta de permissão, e o próximo passo depende do motivo.
- Filtro indisponível responde unavailable, e vale tentar de novo com backoff — mas nada foi publicado nesse meio-tempo.
- Um tipo de mensagem não declarado para esta conversa e uma mensagem grande demais são recusas de validação; o conteúdo nunca é cortado em silêncio.
- Excesso de envios gera uma resposta da categoria rate limit, com um prazo.
- Editar a mensagem de outra pessoa responde forbidden.
- Uma notificação vencida é um conflito: envie uma nova.
Limites
Os números chegam com o capítulo de limites da plataforma; o que acontece em cada teto já está definido.
- Tamanho da mensagem, e anexos com o tamanho deles: o envio é recusado como falha de validação, nunca cortado. Os arquivos em si ficam em Files & UGC.
- Frequência de envios por ator: um rate limit com prazo.
- Profundidade do histórico: passado o período, a mensagem sai da retenção com um evento, em vez de sumir sem aviso.
- Conversas por ator: entrar em mais uma é recusado como conflito.
- Notificações na fila por ator: uma nova é recusada, e descartar é proibido: uma notificação descartada em silêncio pareceria uma que nunca foi enviada.
- Validade de uma notificação:
expired, com um evento. - Participantes numa conversa é um limite de Groups, e bloqueios por ator é um limite de Social — nenhum dos dois se repete aqui.