knowledge/reactive-state.md

416 lines
21 KiB
Markdown
Raw Permalink Normal View History

2026-04-23 16:58:57 +02:00
# 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.