Aller au contenu principal
Fonctionnement du SDK

Cette page n’est pas encore traduite : la version anglaise est affichée.

Threads, cycle de vie et tests

La boucle vous appartient, et le SDK livre à un seul endroit. Le SDK ne lance aucun thread dont vous devriez connaître l’existence, ne vous confie aucun verrou et appelle votre code depuis un contexte unique, choisi par vous au démarrage. Appelez-nous depuis le thread de votre choix ; nous vous appelons depuis un seul.

Un seul contexte de livraison, et la boucle vous appartient

your threads — any numberthe SDKone delivery context — handlers run one at a timegame threadworkerjob task calls, from any threaddeliveries a call from a handler
Les appels entrent depuis n’importe quel thread ; les livraisons sortent sur un seul contexte, un handler à la fois ; un appel lancé depuis un handler répond par une nouvelle livraison

Une instance déclare un seul et unique contexte de livraison — l’endroit où s’exécutent tous ses handlers. Il est fixé à l’initialisation et ne change plus pendant toute la vie de l’instance. Un événement, un delta de données, le résultat d’un appel : tout arrive là, et nulle part ailleurs.

Ce contexte existe sous deux formes, et vous en choisissez une au démarrage :

  • Vous le pompez. Le runtime ne fait rien de lui-même ; c’est votre propre boucle qui vide les livraisons en attente. C’est la forme qui convient à un moteur : les livraisons arrivent sur le thread de jeu, dans la frame que vous avez choisie.
  • Nous le gérons. Le runtime détient un thread d’exécution dédié. C’est la forme qui convient à un hôte en mode console ou à un serveur dédié.

Aucune des deux n’est une solution de repli pour l’autre, et il n’existe pas de troisième option à base de pool de threads : combien de threads le runtime détient n’est jamais une question que votre code a à se poser.

Le contexte n’est jamais un argument. Aucun handler ne reçoit de paramètre « sur quel thread suis-je », et il n’y a rien à interroger. L’endroit où s’exécute votre handler est une propriété du contrat, pas une donnée de l’appel.

Le démarrage et l’arrêt sont explicites

L’initialisation est un appel que vous faites, et elle répond par un résultat. Rien ne s’initialise paresseusement à la première utilisation. Un démarrage paresseux déplacerait le seul endroit où un module désactivé est visible vers l’appel qui se trouverait être le premier, où cela passerait pour un échec de cet appel.

Un module désactivé est signalé au démarrage, et le résultat dit lequel de deux cas s’est produit : soit toute la chaîne dépendante est désactivée, soit vous fonctionnez avec moins, accompagné de la liste de ce qui est indisponible. Il n’y a pas de troisième cas silencieux.

Initialiser avec un résultat explicite, pomper depuis votre propre boucle, arrêter quand vous avez fini
// 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
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

// 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 — fonctionne tel quel dans Unity ; les livraisons arrivent sur le thread principal et le package les vide pour vous
// 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

L’arrêt est explicite, complet et idempotent : une fois qu’il a réussi, plus aucun handler de cette instance n’est appelé.

L’arrêt n’annule pas le travail en cours

L’arrêt ne dit rien des opérations déjà lancées. Chacune reste traçable par le moyen que cette opération a indiqué ; si vous devez savoir si un achat a abouti, posez la question par ce moyen, pas en arrêtant l’instance.

Chaque handle a une seule fin déclarée

Un abonnement, un descripteur de tâche différée, une session : chacun est un handle, et chacun a exactement une fin, nommée par le contrat. La libération est idempotente : libérer deux fois n’est pas une erreur.

Trois conséquences sur lesquelles on se trompe facilement :

  • La fin n’est jamais un finaliseur, un destructeur ni une portée. Un handle que vous laissez tomber reste ouvert. C’est un bug de votre code, pas quelque chose que nous récupérons discrètement — car une durée de vie qui dépendrait du langage serait différente dans chaque binding.
  • Utiliser un handle après sa fin donne un refus déclaré, avec un code. Pas un résultat vide, pas un comportement indéfini, ni une erreur générique d’objet libéré qui ne vous donne rien sur quoi agir.
  • Une connexion perdue n’est pas la fin d’un handle. Un abonnement survit à une déconnexion et continue de recevoir après la reconnexion. Les handles prennent fin pour les raisons que nomme le contrat, et la perte du réseau n’en fait pas partie.

Aucun handle ne survit à l’instance qui l’a émis : une fois l’arrêt effectué, chaque handle qu’elle vous a donné est arrivé à sa fin.

Appeler depuis un handler, oui ; attendre à l’intérieur, non

Appelez l’interface depuis n’importe lequel de vos threads. Chaque handle est utilisable depuis tous les threads, et c’est une promesse, pas une propriété du build actuel. Aucune primitive de synchronisation ne fait partie de l’interface.

Les handlers d’une même instance sont sérialisés : deux ne s’exécutent jamais en même temps, et l’ordre au sein d’un flux est préservé. Un handler n’a donc besoin d’aucun verrou à lui.

Sérialisé ne veut pas dire dédupliqué. L’ordre est une promesse ; le nombre de fois qu’un message est livré en est une autre, déclarée sur le type de message. En at-least-once, vous verrez le même message deux fois, et c’est la clé de déduplication, qui l’accompagne toujours, qui vous permet de le reconnaître.

Lancer une opération depuis un handler est permis et ne peut pas provoquer d’interblocage. Son résultat, en revanche, n’arrive jamais dans ce même handler : il revient sous forme de livraison distincte sur le même contexte.

Ne bloquez jamais le contexte de livraison

Dans un handler, n’attendez ni le réseau, ni le verrou de quelqu’un d’autre, ni, de façon synchrone, votre propre appel.

Cette interdiction a un symptôme : un handler qui garde le contexte au-delà de son budget déclaré produit soit une dégradation déclarée de la livraison, soit un refus déclaré. Il ne produit jamais un ralentissement silencieux que vous découvririez dans la session d’un joueur.

Lancer un travail depuis un handler et rendre la main ; le résultat arrive comme une livraison à part
// 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
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

// 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 — fonctionne tel quel dans Unity avec les types générés
// 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

« Annuler » recouvre deux choses différentes

Un seul mot dans la plupart des langages, deux opérations ici, et la différence est observable :

Ce que vous voulezCe que c’est
ne plus me livrerlocal. Réussit toujours, y compris sans connexion. Libérer un abonnement, c’est cela.
arrêter le travailune requête adressée à la plateforme. Idempotente, et elle ne promet rien sur le fait que le travail ait eu lieu ou non.

C’est sur la seconde qu’on se trompe. Annuler un travail accepté est une requête qui peut ne pas arriver à temps — exactement comme un timeout, qui ne veut pas dire non plus « cela n’a pas été appliqué ». Après l’une ou l’autre forme d’annulation, le résultat d’une opération non idempotente déjà lancée reste traçable par le moyen que cette opération a indiqué.

Et les deux échecs se distinguent : annuler un appel qui ne nous est jamais parvenu est un échec local ; annuler un travail que nous avions accepté vous donne un état terminal parmi un ensemble déclaré.

Ce que vous recevez est une copie du passé

Une valeur livrée à votre handler ne change plus ensuite. Nous ne vous donnons jamais de référence vivante vers notre propre état : rien de ce que vous tenez ne change entre deux lignes de votre code.

Conserver une valeur livrée au-delà du handler est donc sans danger — mais ce que vous gardez est l’observation d’un instant, pas une fenêtre sur le présent. Des deltas peuvent être fusionnés en chemin, si bien qu’une liste de valeurs que vous avez enregistrées n’est pas l’historique de ce qui s’est passé.

Vous ne possédez jamais non plus nos buffers. Pas d’emprunt-restitution, pas d’assemblage avant envoi : modifier l’état déclaré, c’est déjà l’opération réseau.

Tests : une implémentation en mémoire, pas un mock

Il existe une implémentation complète en mémoire — même interface, même ensemble de résultats déclarés, sans réseau. C’est une dépendance distincte, pas une option du runtime de production.

  • Elle n’est pas partielle. Une opération qu’elle ne prend pas en charge est refusée avec un code déclaré, jamais accueillie par un succès inventé.
  • Le temps vous appartient. Les échéances déclarées — la durée de vie d’un descripteur de tâche, une réservation, une fenêtre de rétention — s’atteignent en avançant d’un pas, pas en attendant.
  • Le déterminisme est déclaré et borné : l’ordre au sein d’un flux, le mode de livraison déclaré, un temps contrôlable. Le déterminisme en virgule flottante n’est pas promis, donc une simulation complète ne se rejoue pas non plus ici.

Ce que vous installez, et la version minimale

Le cœur est une unité ; les modules optionnels en sont d’autres, chacun avec une composition déclarée et une liste déclarée de dépendances obligatoires. Ajouter une unité ne change jamais l’interface d’une autre : un module se monte là où sa déclaration l’indique, si bien que rien n’apparaît ni ne disparaît ailleurs à cause de ce que vous avez installé à côté.

Si une unité optionnelle est référencée mais ne peut pas se charger, c’est un résultat déclaré de l’initialisation — au même endroit que le signalement d’un module désactivé. Jamais un bouchon qui, en silence, ne fait rien.

Chaque binding déclare la version minimale du runtime pour laquelle il est construit. En dessous, vous obtenez un refus à l’initialisation, pas un fonctionnement partiel : sinon, un runtime trop ancien casse à la première fonctionnalité qui lui manque, quelque part au hasard dans votre code, et en général sur la machine d’un joueur plutôt que sur la vôtre. Relever ce minimum est un changement cassant, qui suit le même processus que tous les autres.

Pour aller plus loin