02 — L'event sourcing est-il le bon modèle ?
Question posée : « est-ce que l'event sourcing est un modèle intéressant à considérer pour gérer les différents événements successifs et leurs effets ? »
Réponse courte
Oui — et pour ce jeu précisément, c'est plus qu'un choix d'implémentation : c'est ce qui rend possible la fonctionnalité pédagogique centrale. Mais il faut le poser au bon endroit : sur l'état de partie, jamais sur le catalogue de contenu.
| Domaine | Event sourcing ? |
|---|---|
| État de partie (ce que le joueur a fait, ce qui est arrivé) | ✅ oui, journal append-only + snapshots |
| Catalogue de contenu (personnes, scènes, incidents, graphe) | ❌ non, du CRUD versionné en git |
| Comptes, réglages, télémétrie | ❌ non |
Appliquer l'event sourcing au contenu serait l'erreur classique : on n'a pas
besoin de connaître l'historique des modifications d'une fiche de personnage, on
a besoin de git log.
1. Pourquoi ça colle si bien ici
a. Le jeu est déjà un flux d'événements
Le vocabulaire du domaine est celui de l'event sourcing sans le savoir. Un tournage, c'est un rapport de production quotidien : une liste horodatée de faits. « 8h12 — arrivée équipe. 9h40 — premier tour de manivelle. 11h20 — panne groupe électrogène. 14h00 — Marion arrive avec 2h de retard. »
La feuille de service est un plan, le rapport de production est un journal d'événements. On ne plaque pas un motif informatique sur le métier : on recopie le métier.
b. La traçabilité causale est une fonctionnalité de gameplay
C'est l'argument décisif. Le pitch du jeu, c'est la dette différée : « engager quelqu'un au rabais aujourd'hui, le payer dans trois semaines ». Cette mécanique ne vaut que si, à la fin, le jeu peut montrer la chaîne :
Jour 3 → tu as engagé Serge L. à 2 100 €/j au lieu de 3 400 €/j
↳ posé le marqueur chefop.sous-paye
Jour 12 → 3 plans non tournés (installations trop lentes)
↳ marqueur retard.cumule = 0.5 jour
Jour 18 → retard cumulé 2 jours ⇒ 4 jours de tournage en plus
↳ 4 × 41 000 € = 164 000 € hors devis
Jour 24 → le producteur exige un remplacement (incident #47)
Jour 26 → tu as coupé la scène 58 pour rentrer dans l'enveloppe
FIN → le réalisateur note le film 61/100 : « il manque la scène du pont »
Ce panneau de débrief est la fonctionnalité pédagogique n°1 du jeu. Avec un état muté en place, il faut instrumenter à la main chaque cause, on en oublie la moitié et ça pourrit le code métier. Avec un journal, c'est gratuit : la chaîne causale est déjà dans les données, il ne reste qu'à la lire.
C'est ce point-là, plus que tous les avantages techniques, qui tranche la question.
c. Rejouabilité, « et si », variantes
La réponse au formulaire confirme : seed aléatoire, chaque partie diffère.
Avec (contenu, seed, journal) et un réducteur pur :
- rembobiner à n'importe quel tour, gratuitement ;
- proposer « et si j'avais choisi l'autre option ? » : on tronque le journal au choix, on rejoue avec l'autre branche, on compare les deux fins. Énorme en valeur pédagogique, quasi gratuit en implémentation ;
- rejouer la partie en accéléré comme un making-of ;
- comparer deux joueurs sur la même graine (un classement d'école : même scénario, même seed, qui s'en sort le mieux ?).
d. Débogage et équilibrage
Un rapport de bug, c'est un fichier JSON de journal. On le rejoue, on tombe sur
l'état exact. Et le harnais de simulation (packages/engine/sim) produit
10 000 journaux exploitables statistiquement : quels incidents ne se déclenchent
jamais, lesquels sont fatals, où décroche la courbe de difficulté.
e. Sauvegarde et synchro triviales
Un journal append-only se synchronise par concaténation. Pas de fusion, pas de résolution de conflit. La sauvegarde, c'est le journal.
2. Ce que ça coûte — honnêtement
| Coût | Gravité | Mitigation |
|---|---|---|
| Le réducteur doit rester compatible avec les anciens journaux | 🔴 le vrai risque | snapshots obligatoires + contentHash/engineVersion par session ; au-delà, on repart du snapshot, on ne rejoue pas |
| Le hasard doit être déterministe | 🟠 discipline | PRNG seedé par (seedPartie, idÉvénement) ; lint interdisant Math.random() |
| Il faut des projections/snapshots pour ne pas tout rejouer | 🟠 travail réel | snapshot à chaque fin de journée ; l'état complet d'une partie tient largement en mémoire |
| Deux vocabulaires « événement » qui se confondent | 🟠 confusion permanente | nommage strict : GameEvent (fait passé) ≠ Incident (aléa écrit) — cf. data-model/events.md |
Plus verbeux qu'un state.money -= 1000 | 🟢 léger | des créateurs d'événements typés ; c'est le prix normal |
| Sur-ingénierie possible | 🟢 | on ne fait ni CQRS, ni bus, ni store dédié — juste un tableau et une fonction pure |
Le seul piège dangereux est la compatibilité du réducteur. Il se neutralise en décidant tout de suite que le snapshot est le format de sauvegarde de référence, et le journal un enrichissement (rejeu, débrief, « et si ») valable tant que la version n'a pas bougé. Décidé ainsi, le risque tombe à zéro et on garde 100 % des bénéfices.
3. La forme concrète (volontairement minimale)
Pas de framework. Un tableau, une fonction pure, un snapshot.
type Tick = number // 1 tick = une demi-journée
type GameEvent = {
seq: number // ordre strict dans la session
tick: Tick // quand, dans le temps du jeu
type: string // 'casting.comedien.engage'
payload: unknown // typé par discrimination sur `type`
cause?: { // ← la traçabilité, remplie systématiquement
parentSeq?: number // l'événement qui a provoqué celui-ci
incidentId?: string // l'aléa d'où il vient
reason?: string // libellé lisible pour le débrief
}
origin: 'player' | 'engine' | 'director'
}
type Session = {
header: SessionHeader // packId, contentHash, engineVersion, seed
log: GameEvent[] // append-only
snapshots: { seq: number; state: GameState }[]
}
/** Pur. Déterministe. Aucune I/O, aucune horloge, aucun Math.random. */
function reduce(state: GameState, ev: GameEvent): GameState
/** Une intention du joueur → 0..n événements. Peut refuser. */
function decide(state: GameState, cmd: Command, rng: Rng): GameEvent[] | Refusal
Le champ cause est le détail qui fait tout. Le remplir systématiquement,
dès le premier jour, coûte trois lignes par émission ; l'ajouter après coup
coûte une réécriture du moteur. C'est lui qui produit le panneau de débrief, le
fil « pourquoi c'est arrivé », et les cartes de conséquence.
La boucle complète
intention du joueur (Command)
│
▼
decide(state, cmd, rng) ──► GameEvent[]
│ │
│ ├──► append au journal
▼ │
reduce(state, ev) ──► nouvel état ◄┘
│
▼
le directeur d'aléas examine l'état
│
├──► conditions satisfaites ? ──► déclenche un Incident
│ │
│ ▼
│ le joueur choisit une option
│ │
└────────────────────────────────────────┘
(les effets de l'option → GameEvent[])
Une seule boucle. Le joueur et le directeur d'aléas produisent tous les deux des événements ; le réducteur ne fait pas la différence.
4. Ce qu'il ne faut PAS faire
- ❌ Pas de CQRS, pas d'event bus, pas d'Event Store. Un tableau en mémoire, persisté dans RxDB. Toute infrastructure supplémentaire est du poids mort à cette échelle.
- ❌ Pas d'événements « granulaires machine » (
champ.modifie). Les événements sont des faits du métier (tournage.plan.tourne,regie.decor.perdu). Sinon le journal devient illisible et le débrief inexploitable — or le débrief est la raison d'être du dispositif. - ❌ Pas d'event sourcing sur le contenu. L'éditeur fait du CRUD ; git tient l'histoire.
- ❌ Pas de LLM dans le chemin de la réduction. Une sortie non déterministe casse le rejeu. Si on veut du texte généré (et on en veut — cf. la réponse « génération procédurale / LLM à partir de templates »), il est pré-cuit dans le contenu ou purement cosmétique et stocké dans l'événement qui l'a produit, jamais recalculé.
5. Le corollaire à ne pas rater : procédural ≠ IA
La réponse au formulaire demande à la fois du contenu écrit à la main et de la génération procédurale/LLM. Les deux sont compatibles avec l'event sourcing, à condition de les distinguer :
| Déterministe ? | Où ? | |
|---|---|---|
| Procédural seedé (composer une équipe, tirer une météo, varier les tarifs, choisir quels aléas arment la partie) | ✅ oui, fonction de la graine | ✅ dans le moteur, au runtime |
| Génération LLM (écrire le texte d'un SMS, décliner un incident en trois variantes, proposer un dépouillement) | ❌ non | ⚠️ hors du moteur : à l'écriture, dans l'éditeur, ou en étape de « cuisson » d'un pack — le résultat est figé et versionné |
Formulé autrement : le LLM écrit le contenu, la graine choisit dans ce contenu. Une partie reste rejouable à l'identique parce que tout le texte existait déjà avant qu'elle commence.
Ça n'interdit pas un mode « pack généré » : on demande au LLM de produire un scénario complet + son casting + ses aléas, on le valide, on le fige avec un hash, et ensuite il devient jouable — et rejouable. C'est aussi le seul moyen que Dom et Claire puissent relire ce que le modèle a écrit avant qu'un étudiant le joue.
6. Verdict
Adopter l'event sourcing, dans sa forme la plus simple :
GameEvent[]append-only par session, avec un champcausedès le premier commit ;- un réducteur pur dans
packages/engine, sans I/O, sans horloge, sansMath.random; - un snapshot par journée de jeu, qui est le format de sauvegarde de référence ;
contentHash+engineVersiondans l'en-tête de session, qui déterminent si le rejeu est garanti ;- le contenu, lui, reste du CRUD versionné en git.
Ce n'est ni long ni risqué à mettre en place — et ça offre le débrief causal, le « et si », le rejeu accéléré et le harnais d'équilibrage, qui coûteraient chacun un mois s'il fallait les rajouter après coup.