Pular para o conteúdo principal
Como o SDK funciona

Esta página ainda não foi traduzida: mostrando a versão em inglês.

Threads, ciclo de vida e testes

O loop é seu, e o SDK entrega num único lugar. O SDK não inicia nenhuma thread que você precise conhecer, não entrega nenhum lock para você e chama o seu código de um único contexto, que você escolheu na inicialização. Chame a gente de qualquer thread; nós chamamos você de uma só.

Um único contexto de entrega, e o loop é seu

your threads — any numberthe SDKone delivery context — handlers run one at a timegame threadworkerjob task calls, from any threaddeliveries a call from a handler
As chamadas entram por qualquer thread; as entregas saem num único contexto, um handler por vez; uma chamada feita dentro de um handler responde como uma nova entrega

Uma instância declara exatamente um contexto de entrega — o único lugar onde todos os handlers dela rodam. Ele é fixado na inicialização e não muda durante toda a vida da instância. Um evento, um delta de dados, o resultado de uma chamada: tudo chega ali e em nenhum outro lugar.

Esse contexto tem duas formas, e você escolhe uma na inicialização:

  • Você faz o pump. O runtime não faz nada por conta própria; você processa as entregas pendentes no seu próprio loop. É a forma que uma engine quer: as entregas caem na game thread, num frame escolhido por você.
  • Nós cuidamos dele. O runtime mantém uma thread de execução dedicada. É a forma que uma aplicação de console ou um servidor dedicado quer.

Nenhuma das duas é plano B da outra, e não existe uma terceira opção com pool de threads: quantas threads o runtime mantém nunca é uma pergunta que o seu código precise fazer.

O contexto nunca é um argumento. Nenhum handler recebe um parâmetro do tipo “em qual thread estou”, e não há nada para consultar. Onde o seu handler roda é uma propriedade do contrato, não um dado da chamada.

Iniciar e encerrar são atos explícitos

A inicialização é uma chamada que você faz, e ela responde com um resultado. Nada é inicializado sob demanda no primeiro uso. Uma inicialização sob demanda empurraria o único lugar onde um módulo desativado aparece para a chamada que por acaso viesse primeiro, e ali isso pareceria uma falha dessa chamada.

Um módulo desativado é informado na inicialização, e o resultado diz qual de duas coisas aconteceu: toda a cadeia dependente ficou desligada, ou você está rodando com menos, junto com a lista do que está indisponível. Não existe um terceiro caso silencioso.

Inicialize com um resultado explícito, faça o pump no seu próprio loop e encerre quando terminar
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Em breve — Go

Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// 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 — roda como está no Unity; as entregas caem na main thread e o pacote as processa para você
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

O encerramento é explícito, completo e idempotente: depois que ele termina com sucesso, nenhum handler daquela instância é chamado de novo.

Encerrar não cancela o trabalho em andamento

O encerramento não diz nada sobre operações já iniciadas. Cada uma continua podendo ser consultada pelo meio que a própria operação definiu; se você precisa saber se uma compra foi concluída, pergunte por esse meio, e não encerrando o SDK.

Todo handle tem um único fim declarado

Uma assinatura (subscription), um descritor de trabalho adiado, uma sessão: cada um é um handle, e cada um tem exatamente um fim, que o contrato nomeia. Liberar é idempotente, então liberar duas vezes não é erro.

Três consequências em que é fácil errar:

  • O fim nunca é um finalizador, um destrutor ou um escopo. Um handle esquecido continua aberto. Isso é um bug no seu código, não algo que a gente recolhe em silêncio — porque um ciclo de vida que dependesse da linguagem seria diferente em cada binding.
  • Usar um handle depois do fim dele é uma recusa declarada, com código. Não é um resultado vazio, nem comportamento indefinido, nem um erro genérico de objeto descartado que não traz nada que você possa usar.
  • Uma conexão que cai não é o fim de um handle. Uma assinatura sobrevive a uma desconexão e continua recebendo depois da reconexão. Os handles terminam pelos motivos que o contrato nomeia, e perder a rede não é um deles.

Nenhum handle vive mais que a instância que o emitiu: depois que você encerra a instância, todos os handles que ela deu a você chegaram ao fim.

Chamar de dentro de um handler pode; esperar dentro dele, não

Chame a interface a partir de qualquer uma das suas threads. Todo handle pode ser usado de qualquer thread, e isso é uma promessa, não uma característica do build atual. Nenhuma primitiva de sincronização faz parte da interface.

Os handlers de uma instância são serializados: dois nunca rodam ao mesmo tempo, e a ordem dentro de um fluxo (stream) é preservada. Por isso um handler não precisa de lock próprio.

Serializado não quer dizer sem duplicatas. A ordem é uma promessa; quantas vezes uma mensagem é entregue é outra, declarada no tipo da mensagem. Com at-least-once, você vai receber a mesma mensagem duas vezes, e é pela chave de deduplicação que sempre viaja com ela que você percebe isso.

Iniciar uma operação de dentro de um handler é permitido e não causa deadlock. O resultado dela, porém, nunca chega dentro desse mesmo handler: ele volta como uma entrega separada, no mesmo contexto.

Nunca bloqueie o contexto de entrega

Dentro de um handler, não espere pela rede, pelo lock de outro código nem, de forma síncrona, pela sua própria chamada.

A proibição tem um sintoma: um handler que segura o contexto além do orçamento declarado provoca uma degradação declarada da entrega ou uma recusa declarada. O que ele nunca provoca é uma lentidão silenciosa que você só vai descobrir na sessão de um jogador.

Inicie o trabalho a partir de um handler e retorne; o resultado chega como uma entrega própria
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Em breve — Go

Ainda não está decidido como declarar com atributos em Go (O-1). Os exemplos chegam quando a forma de escrita for escolhida.

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// 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 — roda como está no Unity com os tipos gerados
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

“Cancelar” são duas coisas diferentes

Na maioria das linguagens é uma palavra só; aqui são duas operações, e a diferença é observável:

O que você querO que é
parar de receber entregaslocal. Sempre funciona, inclusive com a conexão caída. Liberar uma assinatura é exatamente isso.
parar o trabalhoum pedido à plataforma. Idempotente, e não promete nada sobre o trabalho ter acontecido ou não.

É no segundo que as pessoas erram. Cancelar um trabalho já aceito é um pedido que pode não chegar a tempo — exatamente como um timeout, que também não quer dizer “não foi aplicado”. Depois de qualquer um dos dois tipos de cancelamento, o resultado de uma operação não idempotente já iniciada continua podendo ser consultado pelo meio que a própria operação definiu.

As duas falhas também se distinguem: cancelar uma chamada que nunca chegou até nós é uma falha local; cancelar um trabalho que já tínhamos aceitado dá a você um estado final, de um conjunto declarado.

O que você recebe é uma cópia do passado

Um valor entregue ao seu handler não muda depois. Nunca entregamos uma referência viva ao nosso próprio estado, então nada do que você tem em mãos muda entre duas linhas do seu código.

Por isso é seguro guardar um valor entregue depois que o handler termina — mas o que você guardou é a observação de um momento, não uma janela para o presente. Os deltas podem ser mesclados no caminho até você, então uma lista de valores que você salvou não é um histórico do que aconteceu.

Você também nunca é dono dos nossos buffers. Não existe pegar emprestado e devolver, nem montar e depois enviar: alterar o estado declarado é, por si só, a operação de rede.

Testes: uma implementação em memória, não um mock

Existe uma implementação completa em memória — a mesma interface, o mesmo conjunto de resultados declarados, sem rede. Ela é uma dependência à parte, não uma flag do runtime de produção.

  • Ela não é parcial. Uma operação que ela não suporta é recusada com um código declarado, nunca respondida com um sucesso inventado.
  • O tempo é seu. Prazos declarados — a vida útil de um descritor de trabalho, uma reserva, uma janela de retenção — são alcançados avançando um passo, e não esperando com sleep.
  • O determinismo é declarado e tem limites: a ordem dentro de um fluxo, o modo de entrega declarado, o tempo controlável. O determinismo de ponto flutuante não é prometido, então uma simulação completa também não se reproduz aqui de forma idêntica.

O que você instala e a versão mínima

O núcleo é uma unidade; os módulos opcionais são unidades separadas, cada uma com uma composição declarada e uma lista declarada de dependências obrigatórias. Adicionar uma unidade nunca muda a interface de outra: um módulo é montado onde a declaração dele indica, então nada aparece nem some em outro lugar por causa do que você instalou ao lado.

Se uma unidade opcional é referenciada mas não consegue carregar, isso é um resultado declarado da inicialização, no mesmo lugar em que um módulo desativado é informado. Nunca um stub que, em silêncio, não faz nada.

Todo binding declara a versão mínima de runtime para a qual foi construído. Abaixo dela, você recebe uma recusa na inicialização, e não um funcionamento parcial: sem essa regra, um runtime antigo demais quebra na primeira capacidade que lhe falta, em algum ponto arbitrário do seu código e, em geral, na máquina de um jogador, não na sua. Subir esse mínimo é uma mudança incompatível e passa pelo mesmo processo que qualquer outra.

Próximos passos