415 lines
21 KiB
Markdown
415 lines
21 KiB
Markdown
# reactive-state.md — État réactif et event sourcing
|
|
|
|
Ce fichier couvre ma façon de gérer **l'état** dans une application : comment il est stocké, comment il se mute, comment l'UI se synchronise dessus, comment les effets réseau s'intègrent. C'est la mécanique centrale de presque tous mes projets.
|
|
|
|
Le sujet recoupe deux traditions que j'utilise ensemble : la UI réactive (TinkStateSharp, signals, AutoRun), et l'architecture **Black Box Sim** formalisée par Brian Cronin (Roguelike Celebration 2024) — commande in, événements out, sim isolée de la présentation. J'ai présenté un état précoce de ce pattern à **ADDON 2022** sous l'angle « Rendre réactive sa UI Unity », et je l'ai éprouvé sur Neoproxima, Manitou Lift and Drift, TheRuneMaker, et plusieurs prototypes. Article de référence : [samuel-bouchet.fr/posts/2026-04-08-black-box-sim](https://samuel-bouchet.fr/posts/2026-04-08-black-box-sim/).
|
|
|
|
Trois couches travaillent ensemble :
|
|
|
|
1. **L'état observable** — `Signal<T>`, `SignalList<T>`, `SignalDictionary<K,V>` (TinkStateSharp). Source de vérité.
|
|
2. **Les mutations canalisées** — `GameEvent` / command pattern. Toute écriture passe par là.
|
|
3. **Les souscriptions passives** — `AutoRun`, `ConnectedBehaviour`, `EventBus`. L'UI et les side-effects réagissent aux changements.
|
|
|
|
## Pourquoi ce modèle
|
|
|
|
J'ai adopté ce pattern après avoir souffert de problèmes classiques :
|
|
|
|
- **État dupliqué** qui se désynchronise entre composants.
|
|
- **Mutations sauvages** qui rendent un bug impossible à reproduire.
|
|
- **UI en décalage** parce qu'un appel à `Refresh()` est oublié quelque part.
|
|
- **Multijoueur impossible** parce qu'il n'y a pas de format canonique pour une action.
|
|
- **Replay impossible** parce qu'on ne sait pas ce qui s'est passé.
|
|
|
|
Le pattern réactif + event-sourcé résout les cinq d'un coup :
|
|
|
|
- **Un seul état**, observable. Pas de cache, pas de copie.
|
|
- **Toutes les mutations passent par des GameEvent typés**. Elles sont validables, traçables, rejouables.
|
|
- **L'UI est passive**, elle réagit automatiquement aux changements via des souscriptions.
|
|
- **Un GameEvent est serialisable** (MessagePack) : il transite en réseau, se sauvegarde, se rejoue.
|
|
- **Un changelist produit une trace** des mutations, qui sert à animer, auditer, synchroniser.
|
|
|
|
## L'état observable : Signal, SignalList, SignalDictionary
|
|
|
|
J'utilise **TinkStateSharp** (port C# de `tink_state` de Haxe). Les primitives clés :
|
|
|
|
```csharp
|
|
public readonly Signal<string?> Level = new(null);
|
|
public readonly Signal<byte> SelectedTool = new(0);
|
|
public readonly SignalDictionary<BlockId, int> BlocsInventory = new();
|
|
public readonly SignalList<Rune> Inventory = new();
|
|
```
|
|
|
|
Un `Signal<T>` est une référence mutable observable. On l'utilise comme une variable, mais tout lecteur abonné est notifié à chaque changement. Idem pour `SignalList<T>` (ajout/suppression/réorganisation) et `SignalDictionary<K,V>` (ajout/suppression de clés, mutation de valeurs).
|
|
|
|
Règles que je m'impose :
|
|
|
|
- **Signals immuables structurellement** : je les déclare `readonly` pour que la référence ne change jamais. Seul leur contenu évolue. Côté ConnectedBehaviour, `Observable<T>` expose la version read-only.
|
|
```csharp
|
|
private Signal<LoadingStage> _currentLoadingStage = new(LoadingStage.NotStarted);
|
|
public Observable<LoadingStage> CurrentLoadingStage => _currentLoadingStage;
|
|
```
|
|
- **Pas de `Signal<T>` dans les payloads des GameEvent** — un GameEvent est un message serialisable, pas un conteneur réactif.
|
|
- **Pas d'abonnement hors lifecycle** — chaque souscription est enregistrée dans un `CancellationToken` qui se déclenche au reset ou au destroy.
|
|
|
|
## AutoRun : la souscription qui se gère toute seule
|
|
|
|
`AutoRun` est le pattern clé : on passe un `Action` qui lit des Signal, et tink_state track automatiquement les dépendances. Dès qu'un Signal lu change, l'action est rejouée.
|
|
|
|
```csharp
|
|
this.AutoRun(() => {
|
|
StageText.text = GetStageDescription(CurrentLoadingStage.Value);
|
|
});
|
|
```
|
|
|
|
C'est l'équivalent d'un `useEffect` React sans avoir à déclarer les dépendances. Le tracking est automatique, le cleanup est géré par l'owner (`this` = un `ConnectedBehaviour`).
|
|
|
|
Pattern typique dans un Display :
|
|
|
|
```csharp
|
|
protected override void OnSetup(GameState state) {
|
|
this.AutoRun(() => {
|
|
var gold = state.CurrentRun.Gold.Value;
|
|
GoldText.text = gold.ToString();
|
|
GoldIcon.SetActive(gold > 0);
|
|
});
|
|
|
|
this.AutoRun(() => {
|
|
var phase = state.CurrentRun.CurrentPhase;
|
|
ShopPanel.SetActive(phase == Phase.Build);
|
|
});
|
|
}
|
|
```
|
|
|
|
Chaque `AutoRun` est indépendant, ne lit que ce qui l'intéresse, et se réexécute uniquement quand ses dépendances changent. Le couplage UI ↔ état est totalement déclaratif.
|
|
|
|
## ConnectedBehaviour : le glue côté Unity
|
|
|
|
Côté Unity, j'ai une classe de base qui encapsule la plomberie :
|
|
|
|
- découverte du `GameManager` (et donc du `GameState` et de l'`EventBus`) ;
|
|
- gestion d'un `CancellationToken` reset-able ;
|
|
- API `ListenEvent<T>`, `TriggerEvent<T>`, `DisposeOnReset` ;
|
|
- `OnSetup(GameState)` abstrait : point d'entrée pour poser les `AutoRun`.
|
|
|
|
Version générique `ConnectedBehaviour<T>` pour les composants configurés de l'extérieur :
|
|
|
|
```csharp
|
|
public abstract class ConnectedBehaviour<T> : ConnectedBehaviour {
|
|
public T? Data { get; private set; }
|
|
|
|
public void Set(T data) {
|
|
Data = data;
|
|
_resetSource?.Cancel(false);
|
|
_resetSource = new CancellationTokenSource();
|
|
OnSet(GameManager.State, data);
|
|
}
|
|
|
|
protected abstract void OnSet(GameState state, T element);
|
|
}
|
|
```
|
|
|
|
Chaque appel à `.Set(data)` cancel les anciennes souscriptions et en crée de nouvelles. Un `RuneDisplay` reçoit une `Rune` via `.Set(rune)`, s'abonne aux Signals de cette rune, et quand on le re-Set avec une autre rune, tout se reconnecte proprement.
|
|
|
|
Voir [`unity.md`](unity.md) pour le détail du cycle de vie Unity de `ConnectedBehaviour`.
|
|
|
|
## GameEvent : les mutations canalisées
|
|
|
|
Chaque mutation d'état est un **objet** qui implémente `IGameEvent` :
|
|
|
|
```csharp
|
|
public interface IGameEvent {
|
|
Guid GetId();
|
|
void Apply(GameState gameState, EventBus? sideEffectManager);
|
|
List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus);
|
|
}
|
|
```
|
|
|
|
Une classe abstraite `GameEvent` s'occupe de la plomberie :
|
|
|
|
```csharp
|
|
public abstract class GameEvent : IGameEvent {
|
|
private readonly Guid _id = Guid.NewGuid();
|
|
public Guid GetId() => _id;
|
|
|
|
public void Apply(GameState gameState, EventBus? eventBus) {
|
|
#if UNITY_EDITOR
|
|
AssertApplicationConditions(gameState);
|
|
#endif
|
|
gameState.ApplyEvent(DoApply, eventBus);
|
|
}
|
|
|
|
public List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus) {
|
|
#if UNITY_EDITOR
|
|
AssertApplicationConditions(gameState);
|
|
#endif
|
|
var changelist = new List<ChangeEntry>();
|
|
gameState.ApplyEventWithChangelist(
|
|
(gs, cl) => DoApplyWithChangelist(gs, cl),
|
|
changelist,
|
|
eventBus);
|
|
return changelist;
|
|
}
|
|
|
|
protected abstract void DoApply(GameState gameState, EventBus? eventBus);
|
|
|
|
protected virtual void DoApplyWithChangelist(GameState gameState, List<ChangeEntry> changelist) {
|
|
DoApply(gameState, null);
|
|
}
|
|
|
|
public abstract void AssertApplicationConditions(in GameState gameState);
|
|
}
|
|
```
|
|
|
|
Les composants ne font que créer un event et appeler `.Apply(state)` ou passer par l'EventBus. Ils ne mutent jamais `state` directement.
|
|
|
|
### Pourquoi cette abstraction paye
|
|
|
|
- **`AssertApplicationConditions`** valide en amont que l'event est applicable (assez d'or, phase correcte, rune connectée, etc.). En `#if UNITY_EDITOR` pour ne pas payer en prod, mais ça attrape les bugs tôt.
|
|
- **`DoApply` est le seul endroit qui mute l'état**. Si un bug d'état apparaît, je sais où chercher : dans les `DoApply` des events pertinents.
|
|
- **Le Guid** identifie l'event, utile pour le reporting, le debug, la déduplication en réseau.
|
|
- **Le changelist** produit une trace structurée des mutations pour l'animation / sync.
|
|
|
|
### Conventions de nommage
|
|
|
|
- **Suffixe `GameEvent`** pour chaque classe : `PickLootGameEvent`, `MoveSlotGameEvent`, `BuyFromShopGameEvent`, `BackToTitleGameEvent`. Verbeux mais jamais ambigu.
|
|
- **Un verbe à l'impératif** au début du nom : `Pick`, `Buy`, `Move`, `Apply`, `Quit`. C'est une commande.
|
|
- **Paramètres immuables** : champs `readonly` ou propriétés `init`. Un event une fois créé ne change pas.
|
|
|
|
## ApplyEvent : atomicité et side-effects
|
|
|
|
`GameState.ApplyEvent(...)` est le point central d'écriture. Il garantit :
|
|
|
|
- **pas de réentrance** — un GameEvent ne peut pas en appliquer un autre pendant son `DoApply` ;
|
|
- **un lock** sur la mutation, pour multithread safe ;
|
|
- **un callback `OnEventApplied`** qui déclenche les side-effects via l'EventBus, après la mutation.
|
|
|
|
```csharp
|
|
public void ApplyEvent(Action<GameState, EventBus?> apply, EventBus? eventBus) {
|
|
lock (_lockObject) {
|
|
if (_isApplyingEvent)
|
|
throw new ApplicationException("ApplyEvent cannot be nested");
|
|
_isApplyingEvent = true;
|
|
try {
|
|
apply(this, eventBus);
|
|
OnEventApplied(eventBus);
|
|
} finally {
|
|
_isApplyingEvent = false;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Règle que ce pattern impose : **pas de logique métier dans les souscriptions aux Signals**. Si un changement d'état doit déclencher une autre mutation, c'est un GameEvent qui le fait au niveau supérieur, pas une réaction en chaîne dans un `AutoRun`.
|
|
|
|
## Changelist : la trace rejouable
|
|
|
|
Un GameEvent peut produire un **changelist** : une liste d'entrées typées décrivant ce qui s'est passé. Exemples d'entrées : `GoldChangeEntry`, `RuneMovedEntry`, `CombatStepEntry`, `CombatTargetEntry`, `CombatActionEntry`.
|
|
|
|
```csharp
|
|
protected override void DoApplyWithChangelist(GameState state, List<ChangeEntry> changelist) {
|
|
var before = state.CurrentRun.Gold;
|
|
state.CurrentRun.Gold -= Cost;
|
|
changelist.Add(new GoldChangeEntry(before, state.CurrentRun.Gold, reason: "BuyFromShop"));
|
|
// ... autres mutations et entrées
|
|
}
|
|
```
|
|
|
|
Pourquoi c'est utile :
|
|
|
|
- **Animation** : le `GameManager` récupère le changelist et orchestre les animations synchrones au beat. Un combat entier est résolu instantanément côté logique, puis rejoué visuellement.
|
|
```csharp
|
|
private void HandleChangelist(IReadOnlyList<ChangeEntry> changelist) {
|
|
var combatEntries = changelist
|
|
.Where(e => e is CombatStepEntry or CombatTargetEntry or CombatActionEntry or ...)
|
|
.ToList();
|
|
if (combatEntries.Count > 0)
|
|
StartCoroutine(Orchestrate(combatEntries, token));
|
|
}
|
|
```
|
|
- **Synchronisation réseau** : le serveur applique, produit le changelist, le broadcast aux clients.
|
|
- **Debug et replay** : on peut rejouer une partie en appliquant les events dans l'ordre, ou inspecter les changelist pour comprendre un état final.
|
|
- **UI diff** : au lieu de tout redessiner, on peut n'animer que ce qui a changé.
|
|
|
|
## EventBus : side-effects découplés
|
|
|
|
`EventBus` est un dispatcher typé :
|
|
|
|
```csharp
|
|
public EventDispatcher<T> For<T>() {
|
|
if (_all.TryGetValue(typeof(T), out EventDispatcher obs))
|
|
return (EventDispatcher<T>) obs;
|
|
var newObs = new EventDispatcher<T>();
|
|
_all.Add(typeof(T), newObs);
|
|
return newObs;
|
|
}
|
|
```
|
|
|
|
Usage :
|
|
|
|
```csharp
|
|
// Dans un ConnectedBehaviour
|
|
ListenEvent<CombatStartedEvent>(e => PlayCombatMusic(e.EncounterIndex));
|
|
|
|
// Depuis un GameEvent, pendant DoApply
|
|
eventBus?.For<CombatStartedEvent>().Trigger(new CombatStartedEvent(encounter.Index));
|
|
```
|
|
|
|
Distinction avec les Signals :
|
|
|
|
- **Signal** = état continu, lu à tout moment, déclenche des réactions quand il change.
|
|
- **EventBus** = événement ponctuel, n'a pas d'état persistant, déclenche des side-effects one-shot (son, particule, notification).
|
|
|
|
Un `CharacterDied` est un événement (EventBus). `HP` est un état (Signal). On ne joue pas un son à chaque tick de HP, on en joue un au `CharacterDied`.
|
|
|
|
## Sources de vérité : ce qui vit où
|
|
|
|
Discipline que je m'impose et que je documente en tête de chaque projet :
|
|
|
|
- **Données statiques** (définitions, balance) → factories C# pures (`RuneFactory`, `RuneDungeon`, `BlockConfigJson` registry). Immuables.
|
|
- **État dynamique** → `GameState` (serialisable, observable via Signals). Source unique.
|
|
- **État client local** (caméra, sélection UI, préférences) → `ClientState` séparé, non-serialisé. Ne doit jamais contenir de donnée dérivable du GameState.
|
|
- **Vues** (UI, rendu) → dérivées au runtime depuis les Signals. Jamais stockées à part.
|
|
|
|
Une règle concrète qui émerge : **si tu peux te passer d'un champ parce qu'il est déjà calculable depuis un autre, supprime-le**. Une source de vérité dupliquée est un bug à venir.
|
|
|
|
## State machine : stricte ou lâche
|
|
|
|
Selon le domaine, j'implémente la machine d'état de deux façons :
|
|
|
|
### State machine stricte
|
|
|
|
Pour les transitions bien définies (connexion réseau, chargement de jeu, cycle de vie session) : un enum d'états + une méthode `Transition(State from, State to)` qui valide explicitement.
|
|
|
|
### State machine lâche
|
|
|
|
Pour les phases de gameplay complexes avec des exceptions et des branchements conditionnels : une `Phase` enum **indicative** + des flags implicites (`PendingSpecialisationLoot`, `NecklaceStringBox`, `LootBox`). Les transitions sont déclenchées par des GameEvents ; le client/UI enforce le sequencing, pas l'engine.
|
|
|
|
Exemple documenté dans le CLAUDE.md de TheRuneMaker :
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> Build : StartRunGameEvent
|
|
state Build {
|
|
Deckbuilding --> Deckbuilding : RerollShopGE / BuyFromShopGE / MoveSlotGE
|
|
Deckbuilding --> PickSpec : PendingSpecialisationLoot?
|
|
}
|
|
Build --> Encounter : StartCombatGE
|
|
state Encounter {
|
|
Loot --> Loot : PickLootGE (items remain)
|
|
}
|
|
```
|
|
|
|
Les "guards" réels sont les flags d'état, pas la Phase elle-même. La Phase aide à la compréhension, mais **c'est `RunData.Value = null` qui signale "partie finie"**, pas la valeur de Phase.
|
|
|
|
Ce choix lâche évite la combinatoire des enums de phase mais demande plus de rigueur à la documentation. Je l'assume quand ça reflète fidèlement la complexité du domaine.
|
|
|
|
## Pipelines de calcul : étapes explicites
|
|
|
|
Quand un calcul combine plusieurs effets (bonus, modifiers, multipliers), je pipeline explicitement en étapes nommées. Exemple : le 4-stage bonus pipeline de TheRuneMaker :
|
|
|
|
1. **RECEIVE** (baseValue = 0) : collecte des bonus entrants des runes contributrices.
|
|
2. **GENERATE** (baseValue = ReceivedValue) : additions flat (+10 Power), mods reçus (+50% Burn).
|
|
3. **TRANSFORM** (baseValue = GeneratedValue) : effets multiplicatifs (Efficiency %), critical rolls.
|
|
4. **AMPLIFY** (baseValue = TransformedValue) : multiplicateurs finaux.
|
|
|
|
Chaque étape opère sur la `baseValue` produite par la précédente. L'ordre est documenté, testable, explicable à un designer.
|
|
|
|
Pour les DAG de dépendances (graphe de runes), **tri topologique** pour garantir l'ordre de résolution :
|
|
|
|
```csharp
|
|
var queue = new Queue<RuneSlotState>(
|
|
Edges.Keys.Where(node => inDegree[node] == 0));
|
|
var topoOrder = new List<RuneSlotState>();
|
|
// ... process queue, decrementing inDegree of neighbors
|
|
```
|
|
|
|
Le déterminisme du tri + le pipeline nommé = débuggabilité totale.
|
|
|
|
## Animer une UI réactive : le pattern « animation queue »
|
|
|
|
Une UI purement réactive bindée en `AutoRun` brille tant que les changements sont ponctuels et indépendants. Dès qu'il faut **animer** les transitions — a fortiori une séquence "damage → heal → damage" enchaînée dans un même tick — le binding naïf ne suffit plus. Trois techniques, par complexité croissante :
|
|
|
|
### 1. Transition depuis la valeur courante
|
|
|
|
Cas simple : on accepte que la valeur finale soit la seule « vraie », l'animation est une transition entre l'état précédent et le nouvel état. Kill l'ancienne animation avant de lancer la nouvelle :
|
|
|
|
```csharp
|
|
this.AutoRun(() => {
|
|
var hp = state.Hero.Hp.Value;
|
|
DOTween.Kill(this);
|
|
DOTween.Sequence()
|
|
.Append(HealthFill.DOPunchScale(Vector3.one * 0.1f, 0.15f))
|
|
.Append(HealthFill.DOAnchorMax(new Vector2(hp / maxHp, 1f), 0.3f));
|
|
});
|
|
```
|
|
|
|
Ça couvre 70% des besoins d'UI feedback.
|
|
|
|
### 2. Animation queue + WorldEvents (changelist)
|
|
|
|
Cas multi-étapes : le GameEvent produit une **suite ordonnée** de changements qu'on veut jouer dans le temps, pas collapser vers l'état final. Le GameEvent écrit dans un changelist (`CombatStepEntry`, `DamageChange`, `HealChange`) ; le `GameManager` orchestre le rejeu :
|
|
|
|
- Le changelist est **déjà matérialisé** (combat entièrement résolu instantanément côté engine).
|
|
- Les components s'abonnent via l'EventBus aux entries qui les concernent.
|
|
- L'orchestrateur avance pas à pas, attendant la fin de l'animation la plus lente à chaque step (les subscribers rendent un `UniTask` terminant quand leur animation est finie).
|
|
- Les animations au sein d'un step sont parallèles entre components ; les steps sont séquentiels.
|
|
|
|
C'est le pattern nécessaire dès qu'il y a une temporalité dans les mutations (combat au tour par tour, chaînes de dégâts, effets déclenchés). Article : [samuel-bouchet.fr/posts/2024-12-10-animating-reactive-ui](https://samuel-bouchet.fr/posts/2024-12-10-animating-reactive-ui/).
|
|
|
|
### 3. Merge strategies et cancellation
|
|
|
|
Améliorations que j'ajoute quand le besoin apparaît :
|
|
|
|
- **Merge** : deux animations ciblant la même propriété dans le même tick sont fusionnées (ou la plus récente gagne, avec un petit DOTween `.Kill()`).
|
|
- **Groupement visuel** : des entries liées (tous les dégâts d'un coup critique) peuvent être jouées simultanément plutôt que séquentiellement.
|
|
- **CancellationToken propagé** : un rush du joueur (« passer l'animation ») cancel toute la queue et skippe à l'état final.
|
|
|
|
### Observable seul vs WorldEvents : quand choisir quoi
|
|
|
|
| Besoin | Observable (Signal) | WorldEvent / changelist |
|
|
|---|---|---|
|
|
| UI bindée 1:1 sur un état courant | ✅ | inutilement lourd |
|
|
| Animation entre deux valeurs | ✅ (kill + tween) | overkill |
|
|
| Séquence temporelle multi-étapes | ❌ (perd les étapes intermédiaires) | ✅ |
|
|
| Replay, networking, bots | ❌ | ✅ |
|
|
| Chargement d'un snapshot | ✅ | nécessite rejouer depuis 0 |
|
|
|
|
Règle pratique : **les Signals décrivent « où on en est », les WorldEvents décrivent « ce qui vient de se passer »**. Les deux coexistent dans mes projets, avec des responsabilités claires.
|
|
|
|
## Intégration réseau (client-serveur)
|
|
|
|
Le même event-sourcing fonctionne en multijoueur :
|
|
|
|
- Les GameEvent sont serialisés en MessagePack et broadcastés.
|
|
- Le client applique **optimistiquement** ses events et les envoie au serveur.
|
|
- Le serveur **valide** via `AssertApplicationConditions` puis re-broadcast.
|
|
- En cas de désaccord, le client **rollback + rejoue** dans l'ordre.
|
|
|
|
Trois types de messages (documentés dans `INetworkMessage`) :
|
|
|
|
- **GameEvent** : mutation bidirectionnelle (client ou serveur), validée par serveur, optimistiquement appliquée côté client.
|
|
- **Command** : client → serveur, demande d'opération. Serveur répond par `AckResponse`.
|
|
- **Query** : client → serveur, demande de donnée pure (pas de side-effect). Serveur répond par la response appropriée.
|
|
|
|
Référence : [State Synchronization - Gaffer On Games](https://gafferongames.com/post/state_synchronization/).
|
|
|
|
Voir aussi [`a-trier.md`](a-trier.md) pour les détails de la couche `InputMessage`/`OutputMessage` et la structure multijoueur.
|
|
|
|
## Règles d'usage au quotidien
|
|
|
|
- **Écrire dans l'état = créer un GameEvent**. Jamais d'affectation directe depuis un Display, un Editor script, un bouton UI.
|
|
- **Lire l'état = souscrire via AutoRun** (ou consultation ponctuelle si on est sûr qu'on n'a pas besoin de réactivité).
|
|
- **Chaque Signal a une raison d'être observable**. Si personne ne s'abonne jamais, c'est un champ normal.
|
|
- **Un `ConnectedBehaviour` par concept affiché**, pas par écran. Composition > centralisation.
|
|
- **Toutes les souscriptions sont cancellées** au reset ou au destroy. Jamais de leak.
|
|
- **Un GameEvent est pur** dans son `DoApply` : il ne fait pas d'I/O, pas d'attente, pas de `Random` non-seedé.
|
|
|
|
## Anti-patterns à éviter
|
|
|
|
- Abonner un callback Signal sans `CancellationToken` ou equivalent — leak garanti.
|
|
- Muter un Signal depuis un `AutoRun` qui observe ce même Signal — boucle infinie.
|
|
- Dupliquer une valeur de Signal dans un champ local "pour optimiser" — les deux se désynchronisent.
|
|
- Appliquer un GameEvent depuis un autre GameEvent's `DoApply` — l'atomicité est cassée, l'exception saute.
|
|
- Mettre de la logique de balance ou de règles dans une callback EventBus — c'est un side-effect, pas une mutation.
|
|
- Créer un Signal par valeur dérivable d'un autre — préférer `Observable.Transform` ou un AutoRun qui met à jour un Signal "vue".
|
|
- Passer des `Signal<T>` comme payload d'un GameEvent — les events sont serialisables, pas les signals.
|