Aller au contenu principal

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.

DomaineEvent 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ûtGravitéMitigation
Le réducteur doit rester compatible avec les anciens journaux🔴 le vrai risquesnapshots obligatoires + contentHash/engineVersion par session ; au-delà, on repart du snapshot, on ne rejoue pas
Le hasard doit être déterministe🟠 disciplinePRNG seedé par (seedPartie, idÉvénement) ; lint interdisant Math.random()
Il faut des projections/snapshots pour ne pas tout rejouer🟠 travail réelsnapshot à chaque fin de journée ; l'état complet d'une partie tient largement en mémoire
Deux vocabulaires « événement » qui se confondent🟠 confusion permanentenommage strict : GameEvent (fait passé) ≠ Incident (aléa écrit) — cf. data-model/events.md
Plus verbeux qu'un state.money -= 1000🟢 légerdes 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 grainedans 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 :

  1. GameEvent[] append-only par session, avec un champ cause dès le premier commit ;
  2. un réducteur pur dans packages/engine, sans I/O, sans horloge, sans Math.random ;
  3. un snapshot par journée de jeu, qui est le format de sauvegarde de référence ;
  4. contentHash + engineVersion dans l'en-tête de session, qui déterminent si le rejeu est garanti ;
  5. 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.