Aller au contenu principal

Événements — aléas, conséquences, graphe narratif

Fiche d'entité. Respecte README.md : ContentBase pour les définitions, InstanceBase pour le runtime, Tick = une demi-journée (tick = 2 × jour + 0 matin | 1 après-midi), euros entiers _eur, probabilités 0..1 _p, stats 0–100, Rated pour ce qui peut mentir.

Exemple filé : tout le document déroule le même fil — « le chef opérateur au rabais » — du recrutement au jour 3 à ses conséquences au jour 24.

1. Rôle dans le jeu

Un jeu de direction de production sans aléa est un tableur : on remplit des cases, on optimise, on gagne. Ni amusant ni juste — ce n'est pas le métier. Le plan de travail est un mensonge collectif que tout le monde signe le lundi et que la réalité déchire le mardi. La compétence du directeur de production n'est pas de faire un plan parfait : c'est de savoir quel arbitrage prendre quand le plan casse, avec une information partielle, un budget déjà engagé et douze personnes qui attendent une réponse.

On n'apprend pas le métier en réussissant. On l'apprend en réparant.

PrincipeCe que ça impose au modèle
L'aléa est la matière première du plaisirplus grosse masse éditoriale du jeu ⇒ un format d'auteur confortable, pas des if dans le code
L'aléa est le vecteur pédagogiquechaque aléa porte une notion métier (avenant, assurance, heure sup, plan B décor) et l'explique au débrief
La faute doit être lisible a posterioritraçabilité causale : « ta décision du jour 3 → cet incident du jour 24 »
Le jeu doit rester rejouablepas une liste scriptée : un espace de contenu où un directeur d'aléas pioche selon l'état de la partie
Un aléa doit être réparable≥ 2 options coûteuses et une option lâche. Jamais une seule issue

Règle d'auteur : si le joueur clique « OK » et rien d'autre, l'incident n'existe pas — c'en est la conséquence directe, appliquée silencieusement. Un aléa sans arbitrage n'est pas un aléa, c'est une taxe.

2. Les deux notions d'« événement »

Le mot désigne deux choses opposées dans le temps. On ne les confond jamais.

GameEventle faitIncidentDefl'aléa scénarisé
Natureune ligne immuable du journaldu contenu écrit par un auteur
Tempsle passé — c'est arrivéle futur possible — ça peut arriver
Auteurle moteurle backoffice, versionné en git
Contratni Def ni Instance : le journal dont l'Instance dériveune Def (kind: 'event')
Exemplecasting.comedien.engage, tournage.plan.tourne, meteo.jour.pluvieux« Le chef op met trois heures à éclairer »
Par partiedes milliersquelques dizaines déclenchés, sur un catalogue de centaines

Troisième nom, pour le runtime : IncidentInstance — un IncidentDef effectivement déclenché dans cette partie (présenté / en attente / résolu / expiré, + l'option choisie).

JOURNAL append-only : GameEvent · GameEvent · … ← le passé, immuable
│ réduction (pure)

WorldState people, budget, planning, flags… ← jamais écrit à la main
│ évaluation des Predicate

catalogue ─► DIRECTEUR D'ALÉAS (§9 : éligibilité + budget de tension)
d'IncidentDef │ déclenche
(le futur possible) ▼
IncidentInstance ──► visio / SMS / post-it / ligne compta…
│ le joueur choisit une option

Effect[] ──► émettent de nouveaux GameEvent ──┐
◄──────────────────────────────────────────────────-┘
(la boucle se referme)

Règle d'or : rien ne modifie le WorldState directement. Un Effect n'est pas une mutation, c'est un générateur de GameEvent ; le réducteur est la seule écriture. C'est ce qui rend la partie rejouable, la traçabilité gratuite (§7) et la simulation « et si » possible (§10).

type GameEvent = {
eventId: string; tick: Tick
type: string // 'casting.comedien.engage' — domaine.sujet.verbe_au_passé
payload: Record<string, unknown>
causationId?: string // l'eventId qui a provoqué celui-ci ─┐ traçabilité, §7
correlationId?: string // l'arc narratif ─┘
source: 'player' | 'engine' | 'director' | 'incident'
incidentInstanceId?: string
}

3. Taxonomie des aléas

Elle sert l'auteur (ranger, retrouver), le directeur (anti-répétition par catégorie, §9) et l'UI (icône, couleur, canal par défaut) — donc courte et stable.

type IncidentCategory = 'meteo' | 'humain' | 'technique' | 'logistique'
| 'administratif' | 'financier' | 'artistique' | 'externe'
CatégorieEnseigneExemples
météole plan B décor, l'assurance intempériespluie sur un extérieur jour 3 sur 4 ; canicule, l'HMC ne tient plus, les comédiens fondent en 20 min ; brouillard qui sauve un plan raté la veille ; neige hors saison sur un raccord d'été tourné en juin
humainl'équipe est une matière vivantele 1er assistant caméra se casse le poignet en portant une valise ; la chef costumière et l'ensemblier ne se parlent plus depuis le décor 4 ; la comédienne refuse la scène de nu pourtant au contrat ; le chef machino démissionne à J-2 pour une pub mieux payée ; le chef op annoncé « rapide » met trois heures à éclairer
techniquela redondance, le rapport LTC, le DITla caméra B lâche au milieu d'un champ-contrechamp ; carte CFexpress corrompue, 11 plans perdus ; le groupe électrogène meurt à 22 h en pleine nuit américaine ; le disque de sauvegarde des rushes n'a jamais été branché
logistiquele film est d'abord un problème de transportcamion travelling bloqué au péage, 2 h de retard sur l'appel ; le propriétaire de l'appartement-décor annule 48 h avant ; la cantine livre 40 repas pour 62 personnes ; les costumes d'époque partis dans le mauvais camion, à 300 km
administratif / légalautorisation, déclaration, médecine du travailla mairie retire l'autorisation place du marché la veille (élections) ; contrôle URSSAF sur la figuration déclarée à l'arrache ; accident du travail → déclaration 48 h, inspection, poste arrêté ; un mineur au plateau au-delà de l'horaire légal ; un droit musical jamais négocié sur une scène déjà tournée
financierplan de financement ≠ trésorerieune chaîne se retire du préachat : −350 000 € en plein tournage ; crédit d'impôt refusé, quota de dépenses en France non tenu ; le coproducteur paie six semaines en retard, la paie est à J+3 ; les imprévus consommés à 80 % avant la moitié du tournage
artistiquel'arbitrage vision / faisabilitéle réalisateur veut refaire la scène 12 « autrement », deux jours de plus ; réécriture nocturne, la scène 27 se passe désormais sur un bateau ; le producteur impose une star pour boucler le financement — et son agent impose loge, chauffeur et dates ; le réal refuse le décor validé et veut le vrai lieu, inaccessible en camion
externeon ne contrôle pas le mondegrève des transports le jour du plus gros plateau ; un fait divers rend la scène du commissariat impubliable ; un film concurrent au sujet identique annonce sa sortie ; une pandémie impose un protocole et un référent au budget

Deux axes orthogonaux, portés par des champs et non par la taxonomie : severity: 1..5 (coût en tension, §9) et origin: 'fortuit' | 'consequence' — aléa extérieur (la pluie) ou facture d'une décision antérieure (notre chef op). Le débrief les distingue : « 6 aléas subis, 4 provoqués par vos arbitrages ». C'est la phrase la plus pédagogique du jeu.

4. IncidentDef — le schéma d'auteur

type IncidentDef = ContentBase & {
kind: 'event'
category: IncidentCategory
severity: 1 | 2 | 3 | 4 | 5
origin: 'fortuit' | 'consequence'
teaches: string // la notion métier expliquée au débrief. Obligatoire.
phases: Phase[] // 'developpement' | 'preparation' | 'tournage' | 'post'
trigger: {
when: Predicate // §5 — éligibilité
weight: number // poids relatif du tirage, 0..100 (pas une proba)
cooldown_ticks: number
once: 'per_run' | 'per_arc' | 'repeatable'
minTick?: Tick; maxTick?: Tick
armedBy?: { flag: string; delayTicks: [min: number, max: number] } // §7
}
presentation: {
channel: Channel
from?: EntityRef // qui parle : une personne, un service, personne
subject: string
body: string // markdown + interpolations {{person.chefop.firstName}}
attachments?: Array<{ type: 'photo_plateau'; assetId: string }
| { type: 'piece_jointe'; label: string; docId: string }
| { type: 'extrait_devis'; chapitre: number }>
urgency: 'ambient' | 'now' | 'blocking'
deadlineTicks?: number
onTimeout?: { optionId: string } | 'auto_worst'
}
options: IncidentOption[]
onAppear?: Effect[] // effets inconditionnels, avant tout choix
}

/** Le canal fait le ton et le degré d'interruption — cf. docs/06-ui-desktop-metaphor.md */
type Channel =
| 'visio' // plein écran, ça coupe tout : le réalisateur, le producteur
| 'appel' // le téléphone sonne — on peut ne pas décrocher (mais…)
| 'sms' // discret, familier : l'équipe entre elle
| 'mail' // formel, avec pièce jointe : administratif et légal
| 'postit' // collé sur l'écran par l'assistante, sans expéditeur
| 'photo_plateau' // une photo arrive du plateau, à interpréter soi-même
| 'ligne_compta' // une ligne paraît dans le devis, SANS notification. Le joueur
// doit la voir. Le canal le plus cruel du jeu.
| 'presse' // un article, une brève, une rumeur

type IncidentOption = {
id: string
label: string // « Le remplacer » — infinitif, court
hint?: string // le conseil de l'assistante, souvent partial
visibleWhen?: Predicate // sinon l'option n'est même pas affichée
enabledWhen?: Predicate; disabledReason?: string // affichée mais grisée
cost?: { money_eur?: number; ticks?: number; capital?: number }
outcomes: Outcome[]
}
type Outcome = {
when?: Predicate // défaut : vrai
weight?: number // tirage pondéré entre outcomes éligibles
narration: string
effects: Effect[] // §6 — liste fermée
goto?: string // id d'un nœud du graphe explicite — §8
}

Le fil, nœud N1 — « Trois heures pour éclairer » (jour 12, tick 24)

{ "slug": "chefop-lenteur-jour1", "kind": "event", "name": "Trois heures pour éclairer",
"category": "humain", "severity": 2, "origin": "consequence",
"teaches": "Un tarif très bas signale une compétence surévaluée ou une mauvaise volonté à venir. Le tarif est une information.",
"phases": ["tournage"],
"trigger": {
"when": { "all": [
{ "has": { "flag": "chefop.sous_paye" } },
{ "cmp": [{ "ref": "person(poste=image,role=chef).stats.rapidite.actual" }, "lt", 45] },
{ "cmp": [{ "ref": "schedule.today.scenes_prevues" }, "gte", 2] } ]},
"weight": 70, "cooldown_ticks": 6, "once": "repeatable",
"armedBy": { "flag": "chefop.sous_paye", "delayTicks": [16, 30] } },
"presentation": {
"channel": "sms", "from": { "kind": "person", "role": "premier_assistant_realisation" },
"body": "11h40. Pas un plan de fait. {{person.chefop.firstName}} refait la lumière pour la 3e fois. Le réal tourne en rond.",
"urgency": "now", "deadlineTicks": 1, "onTimeout": "auto_worst" },
"options": [
{ "id": "laisser", "label": "Le laisser finir", "hint": "« C'est son métier, pas le tien. »",
"outcomes": [{ "narration": "La lumière est belle. On a perdu la matinée.", "effects": [
{ "op": "time.consume", "ticks": 1, "reason": "lumiere" },
{ "op": "stat.modify", "target": "shot.today.*", "stat": "image", "delta": 8 },
{ "op": "debt.increase", "debtId": "chefop.retard", "amount": 1 } ]}] },
{ "id": "presser", "label": "Aller le presser sur le plateau", "cost": { "capital": 1 },
"outcomes": [
{ "when": { "cmp": [{ "ref": "person.chefop.moral" }, "gte", 45] }, "weight": 3,
"narration": "Il grommelle, mais il boucle en vingt minutes.",
"effects": [{ "op": "stat.modify", "target": "person.chefop", "stat": "moral", "delta": -8 }] },
{ "weight": 2,
"narration": "« Tu m'as payé la moitié de mon tarif, tu ne vas pas en plus me presser. » Le plateau a entendu.",
"effects": [
{ "op": "truth.reveal", "target": "person.chefop", "stat": "rapidite" },
{ "op": "stat.modify", "target": "person.chefop", "stat": "moral", "delta": -15 },
{ "op": "stat.modify", "target": "crew", "stat": "moral", "delta": -4 },
{ "op": "flag.set", "flag": "chefop.conflit_ouvert", "scope": "run" },
{ "op": "time.consume", "ticks": 1, "reason": "lumiere" } ]} ]},
{ "id": "couper", "label": "Supprimer un plan de la journée",
"visibleWhen": { "cmp": [{ "ref": "schedule.today.plans_restants" }, "gte", 3] },
"outcomes": [{ "narration": "Le réalisateur signe le découpage réduit. Il ne dit rien. Il n'oubliera pas.",
"effects": [
{ "op": "scene.reduce", "target": "scene.today", "shots": 1 },
{ "op": "relation.shift", "a": "player", "b": "person.realisateur", "delta": -6 },
{ "op": "chekhov.plant", "id": "plan_manquant_scene" } ]}] } ] }

5. Le langage de conditions

Le choix : arbre de prédicats JSON typé — pas du JS

CritèreJS en sandboxmini-DSL textearbre JSON
Expressivitétotalebonnevolontairement bornée
Éditable dans une UI de graphenonà moitiéoui, nativement (un widget par nœud)
Diff gitmauvaiscorrectexcellent (structurel)
Analysable statiquement (§10)impossibledifficiletrivial — on parcourt l'arbre
Déterminisme au replayfragilebongaranti
Sécurité (contenu venu de PocketBase)exécution arbitrairesûrsûr
Coût d'écriturefaiblefaiblemoyen, compensé par l'éditeur visuel

Compromis assumé : on perd de l'expressivité (pas de boucle, pas de variable, pas de fonction ad hoc). On gagne les deux choses non négociables : le contenu est validable avant publication et rejouable à l'identique dans six mois (partie locale-first, cf. docs/01-architecture.md). Quand une condition est trop complexe pour l'arbre, la réponse n'est pas « ouvrons le JS » : c'est ajouter un champ dérivé au WorldState (schedule.retard_cumule_jours), calculé par le réducteur, testé unitairement, offert à tous les auteurs. Le langage grandit par son vocabulaire, pas par sa syntaxe.

type Predicate =
| { all: Predicate[] } | { any: Predicate[] } | { not: Predicate }
| { cmp: [Term, CmpOp, Term] }
| { has: { flag: string; scope?: 'run' | 'arc' | 'entity'; on?: string } }
| { phase: Phase[] } // sucre pour cmp world.phase in […]
| { exists: { of: EntityKind; where: Predicate } }
| true | false // littéraux, utiles au brouillon
type CmpOp = 'eq'|'ne'|'lt'|'lte'|'gt'|'gte'|'in'|'nin'|'between'|'contains'
type Term =
| number | string | boolean | Array<string | number>
| { ref: string } // racine(sélecteur).champ[.champ…]
| { agg: 'count'|'sum'|'avg'|'min'|'max'; of: EntityKind; field?: string; where?: Predicate }
| { add: Term[] } | { sub: [Term, Term] } | { ratio: [Term, Term] }

Racines de ref : world, production, budget, schedule, person, element, scene, shot, deal, flag, debt, arc. Sélecteur = liste clé=valeur ; person.chefop est un alias de rôle défini dans content/reference/roles.json.

Deux interdits délibérés. (1) Aucun hasard dans un Predicate — pas de { chance: 0.3 } : le hasard vit dans trigger.weight et Outcome.weight, jamais dans l'éligibilité, sinon l'analyseur de §10 ne prouve plus rien et le débrief ne peut plus dire pourquoi un incident s'est déclenché. (2) L'auteur peut lire un actual non révélé — c'est le cœur du jeu — mais l'analyseur avertit si une option visible en dépend : ce serait afficher au joueur une information qu'il n'a pas.

// 1. Le cas canonique : chef op au rabais, on tourne, et il pleut.
{ "all": [ { "phase": ["tournage"] },
{ "cmp": [{ "ratio": [{ "ref": "deal(poste=image,role=chef).tarif_jour_eur" },
{ "ref": "person.chefop.tarif_reference_eur" }] }, "lt", 0.70] },
{ "cmp": [{ "ref": "world.meteo.today" }, "in", ["pluie", "orage"]] } ]}

// 2. Les imprévus cramés avant la moitié du tournage.
{ "all": [ { "cmp": [{ "ref": "budget.chapitre(10).consomme_ratio" }, "gt", 0.8] },
{ "cmp": [{ "ref": "schedule.avancement_ratio" }, "lt", 0.5] } ]}

// 3. Deux personnes au moins sont au bout du rouleau.
{ "cmp": [{ "agg": "count", "of": "person", "where": { "all": [
{ "cmp": [{ "ref": "person.statut" }, "eq", "engage"] },
{ "cmp": [{ "ref": "person.moral" }, "lt", 30] } ]}}, "gte", 2] }

// 4. Décor 4 perdu, aucun plan B, et il reste des scènes dedans.
{ "all": [ { "has": { "flag": "decor.4.perdu", "scope": "run" } },
{ "not": { "exists": { "of": "element",
"where": { "cmp": [{ "ref": "element.tags" }, "contains", "backup-decor-4"] } } } },
{ "cmp": [{ "agg": "count", "of": "scene", "where": { "all": [
{ "cmp": [{ "ref": "scene.decorId" }, "eq", "decor-4"] },
{ "cmp": [{ "ref": "scene.statut" }, "ne", "tournee"] } ]}}, "gt", 0] } ]}

// 5. Le fil, jour 20 : la dette de retard a mûri, on est en acte 2 finissant.
{ "all": [ { "cmp": [{ "ref": "debt(chefop.retard).amount" }, "gte", 3] },
{ "cmp": [{ "ref": "schedule.retard_cumule_jours" }, "gte", 2] },
{ "cmp": [{ "ref": "world.acte" }, "gte", 2] },
{ "not": { "has": { "flag": "chefop.remplace", "scope": "run" } } } ]}

6. Le langage d'effets — une liste fermée

type Effect =
// — chiffres —
| { op: 'stat.modify'; target: Ref; stat: string; delta: number; decayTicks?: number }
| { op: 'stat.set'; target: Ref; stat: string; value: number }
| { op: 'money.spend'; chapitre: number; amount_eur: number; label: string }
| { op: 'money.commit'; chapitre: number; amount_eur: number; dueTick: Tick; label: string }
| { op: 'money.grant'; chapitre?: number; amount_eur: number; label: string }
| { op: 'time.consume'; ticks: number; reason: string }
// — état des choses —
| { op: 'status.set'; target: Ref; status: string }
| { op: 'roster.add'; kind: 'person' | 'element'; defId: string; terms?: DealTerms }
| { op: 'roster.remove'; target: Ref; reason: string }
| { op: 'scene.lock'; target: Ref; until?: Tick; reason: string }
| { op: 'scene.unlock'; target: Ref }
| { op: 'scene.reduce'; target: Ref; shots: number }
| { op: 'schedule.move'; target: Ref; byTicks: number }
| { op: 'relation.shift'; a: Ref; b: Ref; delta: number }
// — le temps long (§7) —
| { op: 'flag.set'; flag: string; scope: 'run'|'arc'|'entity'; on?: Ref; ttlTicks?: number }
| { op: 'flag.clear'; flag: string; on?: Ref }
| { op: 'debt.open'; debtId: string; kind: DebtKind; amount: number; note: string }
| { op: 'debt.increase'; debtId: string; amount: number }
| { op: 'debt.settle'; debtId: string }
| { op: 'incident.schedule'; incidentId: string; inTicks: [number, number]; unless?: Predicate }
| { op: 'incident.cancel'; incidentId: string }
| { op: 'chekhov.plant'; id: string; payload?: Record<string, unknown> }
| { op: 'chekhov.fire'; id: string }
// — information —
| { op: 'truth.reveal'; target: Ref; stat: string }
| { op: 'journal.note'; text: string; pin?: boolean }
| { op: 'arc.advance'; arcId: string; toNode: string }

Vingt-quatre opérations, et c'est tout. Pourquoi pas un onResolve(state) { … } : (1) le journal doit être rejouable — chaque Effect devient un GameEvent typé, là où un script produit un diff opaque, irrejouable après une mise à jour du moteur (et une partie locale-first survit à plusieurs versions) ; (2) l'UI sait rendre chaque effetmoney.spend fait voler un −3 500 € au-dessus du chapitre 6, truth.reveal déchire la fiche du CV, time.consume mange une case du plan de travail : l'effet typé est la seule chose qui puisse s'animer ; (3) débrief et équilibrage sont des agrégations — « les aléas vous ont coûté 47 000 € et 4,5 jours » ne se calcule qu'en sommant des effets typés, sur une partie ou sur dix mille ; (4) la validation statique (§10) devient possiblemoney.spend sur un chapitre inexistant, chekhov.fire d'un fusil que personne ne pose ; (5) la simulation « et si » a besoin d'effets analysables pour prévisualiser sans commettre le journal ; (6) la contrainte fait écrire mieux — un auteur qui ne peut pas coder cherche la conséquence dramatique plutôt qu'arithmétique.

Coût accepté : quelques aléas très spécifiques resteront inexprimables. On généralise alors l'effet (il entre dans la liste, en connaissance de cause) ou on renonce à l'aléa. Jamais de porte de sortie scriptée : dès qu'elle existe, tout le contenu difficile y passe et les six garanties tombent en une semaine.

7. Les conséquences différées — le mécanisme signature

Engager quelqu'un au rabais aujourd'hui, payer le prix dans trois semaines.

type Flag = { // un fait mémorisé — passif, l'accroche des conditions
flag: string; scope: 'run' | 'arc' | 'entity'; on?: string
setAtTick: Tick; setByEventId: string; ttlTicks?: number }
type Debt = { // une facture qui grossit et finira par être présentée
debtId: string; kind: 'retard'|'moral'|'qualite'|'tresorerie'|'reputation'|'legal'
amount: number // un compteur de pression, pas des euros
threshold: number // au-delà, un incident de recouvrement devient éligible
openedAtTick: Tick; openedByEventId: string
contributions: Array<{ tick: Tick; amount: number; eventId: string }> }
type ArmedIncident = { // un aléa déjà décidé, en attente de sa fenêtre
armedIncidentId: string; incidentDefId: string; windowTicks: [Tick, Tick]
unless?: Predicate // désarmement conditionnel : le joueur peut encore l'éviter
armedByEventId: string; arcId?: string }
type Chekhov = { // un fusil au mur : une promesse narrative
id: string; plantedAtTick: Tick; plantedByEventId: string; firedAtTick?: Tick }
MécaniqueSémantiqueQuand
Flag« ce fait est vrai » — ne déclenche rien seulrendre un aléa éligible plus tard (le chef op sous-payé)
Debt« la pression monte », un compteur à seuilquand la conséquence dépend d'une accumulation
ArmedIncident« ça arrivera entre le tick 40 et le 48 »quand la conséquence est certaine, seule la date est floue

Anti-pattern à bannir : incident.schedule avec inTicks: [21, 21]. Une conséquence à date fixe est un piège, pas une leçon. Toujours une fenêtre, et presque toujours un unless — le joueur attentif doit pouvoir désamorcer. La sanction se mérite dans les deux sens.

La traçabilité : pourquoi l'event sourcing la rend gratuite

Chaque GameEvent porte causationId et correlationId : le journal est, sans effort, une forêt causale. Le débrief remonte les causationId jusqu'à une racine source: 'player'.

J3 t7 casting.technicien.engage {chefop, 380 €/j vs réf. 700} E1 source: player
├─ flag.set chefop.sous_paye → E2 (causationId E1)
└─ debt.open chefop.retard, threshold 3 → E3 (causationId E1)
J12 t24 directeur : « chefop-lenteur-jour1 » éligible via E2
alea.incident.declenche E4 (causationId E2, correlationId arc-chefop)
alea.option.choisie « presser » E5 (causationId E4)
├─ truth.reveal rapidite → E6 « il n'a jamais été rapide »
└─ debt.increase → E7 (1 → 2)
J15/J17 deux redéclenchements → E8, E9 (2 → 4 : seuil franchi)
J20 t40 planning.retard.constate {jours: 2.5} E10
J24 t48 « producteur-exige-remplacement » (cond. §5 ex. 5)
alea.incident.declenche E11 (causationId E10, correlationId arc-chefop)

DÉBRIEF — remontée E11 → E10 → E9 → E7 → E5 → E4 → E2 → E1 :
« Jour 3 : vous avez engagé Pascal Fabre à 54 % de son tarif de référence.
Jour 24 : le producteur a exigé son remplacement. Coût du fil : 2,5 jours,
18 400 €, moral d'équipe −11. Économie réalisée au jour 3 : 6 400 €. »

Cette dernière phrase — l'économie initiale confrontée à la facture finale — est produite mécaniquement par la remontée causale : aucun contenu à écrire pour l'obtenir, c'est le journal qui parle. C'est le seul argument décisif en faveur de l'event sourcing dans ce projet, et il justifie à lui seul son coût.

8. Le graphe nodal — modèle de données

Graphe explicite (Twine, Ink, Disco Elysium) : nœuds et arêtes dessinées, l'auteur voit son histoire, les arcs longs sont maîtrisés, le rythme est écrit. Mais ça explose combinatoirement (chaque état du monde voudrait sa branche), ça se rejoue à l'identique, et la pluie n'a pas sa place dans une arête.

Système à base de règles (Reigns, l'AI Director de Left 4 Dead, Rimworld) : aucune arête, chaque incident déclare ses conditions, un directeur pioche. Émergent, rejouable, résilient à l'ajout de contenu. Mais aucun arc de plus de deux coups ne tient, l'auteur perd la vue d'ensemble, et le jeu devient une suite de vignettes sans mémoire — le défaut exact à éviter ici, puisque la mémoire est le sujet (§7).

Recommandation : hybride par couche, pas par compromis.

Arcs (graphe explicite)Bruit de fond (règles)
Volume6 à 10 fils par scénario, écrits à la main150 à 300 incidents indépendants
Structurenœuds + arêtespool plat, aucune arête
Déclenchementl'arc avance de nœud en nœudconditions + poids, tirage du directeur
Rôlela dramaturgie, la leçon, la montée en acte 3la texture, la friction, l'imprévu
Exemplesle chef op au rabais ; la star imposée ; le décor perdula pluie, le camion, la cantine
Tension (§9)prioritaire — un arc n'attend pascomble ce qui reste

La frontière tient à une chose : un ArcNode référence un IncidentDef, il ne le remplace pas. Le même schéma d'auteur sert dans les deux couches, seul le mode de sélection change ; un incident du pool peut donc être promu dans un arc sans être réécrit, et Outcome.goto est le seul pont entre les deux mondes.

/** Couche 1 — l'arc écrit à la main. */
type NarrativeArc = ContentBase & {
kind: 'event'; arcKind: 'arc'
theme: string // « la fausse économie »
entryNode: string
entryWhen: Predicate // ce qui ouvre l'arc — souvent un flag joueur
exclusiveWith?: string[] // arcs mutuellement exclusifs (charge narrative)
nodes: ArcNode[]; edges: ArcEdge[] }
type ArcNode = {
nodeId: string
incidentDefId: string // le nœud EST un IncidentDef, référencé
act?: 1 | 2 | 3 // pour la courbe de difficulté (§9)
terminal?: 'resolu' | 'echec' | 'pourri'
epilogue?: Effect[] }
type ArcEdge = {
from: string; to: string
onOption?: string // ─┐ exactement un des trois :
when?: Predicate // │ l'option choisie, une condition
after?: [minTicks: number, maxTicks: number] // ─┘ d'état, ou le temps qui passe
guard?: Predicate; priority?: number }

/** Couche 2 — le bruit de fond, sans arêtes. */
type AmbientPool = {
poolId: string; incidentDefIds: string[]
weightsByPhase: Partial<Record<Phase, number>> // par-dessus trigger.weight
categoryQuota?: Partial<Record<IncidentCategory, number>> } // % max par acte

Le fil complet — « le chef opérateur au rabais »

ARC arc-chefop-rabais · theme « la fausse économie » · entryWhen: flag chefop.sous_paye

[J3 · t7] ⟨hors arc⟩ DÉCISION JOUEUR — Pascal Fabre, chef op.
Rated rapidite : claimed 72 / actual 38. Réf. 700 €/j → négocié 380 (54 %).
Économie 6 400 €. effects: flag.set chefop.sous_paye · debt.open chefop.retard(3)
│ entryWhen satisfait → l'arc s'ouvre
┌─────────────────▼ N1 · chefop-lenteur-jour1 ──────────────── act 2 · sev 2 ─┐
│ SMS du 1er assistant, 11h40, aucun plan tourné. armedBy [16,30] ⇒ J11–J18 │
│ when: flag chefop.sous_paye ∧ rapidite.actual < 45 ∧ scenes_prevues ≥ 2 │
│ ○ laisser → time.consume 1 · image +8 · debt +1 │
│ ○ presser → 60 % moral −8 · 40 % truth.reveal(rapidite) + conflit ouvert │
│ ○ couper → scene.reduce 1 · relation(réal) −6 · chekhov.plant │
└──────┬───────────────────────────────────┬──────────────────────────────────┘
edge after [4,10] ∧ debt ≥ 2 edge onOption "couper" → N1' « le réal ne
│ signe plus le découpage » (variante)
┌──────▼ N2 · planning-craque ──────────────────────────────── act 2 · sev 3 ─┐
│ [J20 · t40] canal ligne_compta — AUCUNE notification. Le plan de travail │
│ révisé paraît : 2,5 jours de retard, +18 400 € d'heures sup en chapitre 2. │
│ when: debt(chefop.retard) ≥ 3 ∧ retard_cumule_jours ≥ 2 │
│ onAppear: money.commit ch.2 18 400 € · journal.note(pin) │
│ ○ absorber sur les imprévus → money.spend ch.10 │
│ ○ rogner 2 jours de tournage → scene.lock ×2 · relation(réal) −12 │
│ ○ ne rien faire → debt +2 · flag budget.derive │
└──────┬───────────────────────────────────────────────────────────────────────┘
edge when: debt ≥ 4 ∨ flag budget.derive (∅ sinon → l'arc reste en veille)
┌──────▼ N3 · producteur-exige-remplacement ────────────────── act 3 · sev 4 ─┐
│ [J24 · t48] canal VISIO, plein écran, 8 h du matin. once per_run · blocking │
│ when: §5 exemple 5 │
│ ○ « Je le remplace » → roster.remove + roster.add(2 200 €/j) · │
│ money.spend ch.2 ≈11 000 € · crew.moral −6 · 1 j perdu → N4a │
│ ○ « Je le garde, j'assume » visibleWhen relation(producteur) ≥ 40 │
│ relation(producteur) −20 → N4b │
│ ○ « Je le garde et je paie son tarif réel » visibleWhen ch10.reste > 0.25 │
│ money.spend le différentiel rétroactif · moral +25 · debt.settle · │
│ truth.reveal · relation(chefop) +15 → N4c │
│ ○ « Je démissionne » visibleWhen stress ≥ 80 — fin alternative │
└──┬───────────────────┬───────────────────────────────┬─────────────────────┘
▼ N4a resolu ▼ N4b pourri ▼ N4c resolu
image −5 jusqu'à le retard court jusqu'au bout ; image +12 ; imprévus
la fin (le rempla- chekhov « le film n'est pas quasi vides : le moindre
çant découvre le fini » armé pour la post ; aléa suivant fera très mal
film en marche) epilogue flag film.bancal epilogue debt.settle

Bon test du modèle : quatre nœuds, trois canaux d'UI, une révélation de Rated, une dette qui mûrit, une option de rattrapage coûteuse mais gagnante, aucune issue gratuite. N2 arrive par ligne_compta : pas de notification, juste une ligne dans le devis. Le joueur qui ne lit pas son devis découvre le problème en visio, jour 24, à 8 h du matin.

9. Le directeur d'aléas

Composant pur(WorldState, journal, catalogue, seed) → IncidentDef | null, appelé une fois par tick. Pur ⇒ rejouable ⇒ testable ⇒ simulable en masse. Il arbitre cinq contraintes : (1) un budget de tension, chaque incident coûtant severity points, plafonné sur une fenêtre glissante de 8 ticks ; (2) une courbe de difficulté — acte 1 bas (on apprend), acte 2 plateau haut avec des respirations, acte 3 montée franche ; (3) l'anti-répétition — cooldown par def, quota par catégorie, jamais deux fois la même catégorie d'affilée (trois pannes techniques de suite se lisent comme un bug, pas comme du malheur) ; (4) une garantie de pacing — un compteur de silence qui, passé un seuil, abaisse les exigences : le vide est pire que le bruit ; (5) la priorité aux arcs, sans quoi les fils longs meurent étouffés par le bruit de fond.

à chaque tick t :
0. dueArcs ← ArmedIncident dont la fenêtre couvre t et dont `unless` est faux
si non vide → déclencher le plus ancien. RETURN.
(une conséquence armée est une promesse ; elle ne négocie pas)
1. marge ← tensionBudget(acte(t), phase(t)) − Σ severity des 8 derniers ticks
si marge ≤ 0 et silence < seuilSilence → RETURN null
2. candidats ← catalogue filtré par :
phase(t) ∈ def.phases · t ∈ [minTick, maxTick] · once / cooldown respectés
evaluate(def.trigger.when, world) == true
def.severity ≤ marge + tolerance(silence) // le vide force la main
3. score(def) = def.trigger.weight × poolWeight(phase)
× 3.0 si def est le nœud courant d'un arc ouvert
× 0.25 si sa catégorie est celle du dernier incident déclenché
× (1 − saturationCategorie(cat, acte))
× noveltyBonus(def, journal) // monte s'il n'est jamais sorti de la partie
× escalade(acte) // acte 3 : ×1.6 sur sev ≥ 3, ×0.5 sur sev 1
4. si Σ score == 0 → silence += 1 ; RETURN null
sinon tirage pondéré avec le PRNG seedé (seed, t) ; silence ← 0

Deux détails font la différence. escalade(acte) n'augmente pas seulement la fréquence en acte 3 : il augmente la sévérité moyenne et abaisse la pénalité de répétition de catégorie. C'est exactement l'effet Lost in La Mancha — ce n'est pas qu'il arrive plus de choses, c'est que les mêmes choses recommencent, en pire, pendant que la précédente n'est pas réglée. Et tolerance(silence) autorise le dépassement de budget quand il ne s'est rien passé : un joueur en plein calme ne se dit pas « quel équilibrage élégant », il s'ennuie.

10. Édition dans le backoffice

L'écran de graphe. Un canvas par NarrativeArc. Chaque nœud est une carte : titre, catégorie (couleur), sévérité (épaisseur du liseré), canal (icône), acte, et le statut de ContentBase.authoring (brouillon hachuré, relecture orange, prêt plein). Les arêtes portent leur étiquette : onOption:"presser", after [4,10], ou le résumé du when. Panneau latéral à trois onglets : Conditions (l'arbre de prédicats en widgets imbriqués, avec autocomplétion des ref sur le schéma du WorldState — le bénéfice concret d'avoir refusé le JS), Présentation (aperçu rendu dans le vrai canal : le SMS ressemble à un SMS), Options & effets (une colonne par option, effets en pastilles typées). Le pool ambiant n'a pas de canvas : un tableau filtrable avec un histogramme de couverture (« acte 1 : 4 aléas financiers, 0 artistique »).

Tester un fil sans jouerCe que ça fait
État de départ arbitraireon pose un WorldState à la main (t40, tournage, flag chefop.sous_paye, dette 3) et on évalue l'éligibilité de tous les nœuds. Rouge/vert par nœud, avec le sous-prédicat exact qui bloque surligné dans l'arbre.
« Et si »depuis un nœud, on clique une option : effets appliqués en spéculatif (journal éphémère, jamais commité), diff avant/après du WorldState, arêtes devenues franchissables en surbrillance. Possible uniquement parce que les effets sont une liste fermée (§6).
Monte-Carlo10 000 parties avec un joueur artificiel (aléatoire / « toujours le moins cher » / « toujours le plus sûr »). Sorties : taux de déclenchement par nœud, distribution des ticks d'apparition, taux d'atteinte de chaque terminal, coût moyen de l'arc en euros et en jours. C'est l'outil d'équilibrage réel : les intuitions d'auteur sur les probabilités sont systématiquement fausses.

Validation statique — exigence de première classe. Le contenu est du code : il passe en CI, il casse le build. Aucun packId ne se publie si sa validation est rouge.

  • Intégrité référentielle — tout incidentDefId, defId, chapitre, assetId, goto, optionId pointe sur quelque chose. La plus bête, la plus rentable.
  • Satisfiabilité — l'arbre étant analysable, on détecte les contradictions : phase == 'preparation' dans un def phases: ['tournage'] ; x > 80 ∧ x < 20 ; minTick > maxTick ; une condition sur un flag qu'aucun effet du pack ne pose. Un nœud injouable est une erreur, pas un avertissement — c'est du travail d'auteur perdu, silencieusement.
  • Accessibilité du graphe — tous les nœuds atteignables depuis entryNode ; tout nœud non terminal a une arête sortante franchissable ; pas de cycle sans progression (deux nœuds qui se renvoient la balle sans changer l'état) ; chaque terminal atteignable par au moins un chemin.
  • Cohérence flags / dettes / fusils — flag posé jamais lu (contenu mort) ; flag lu jamais posé (nœud injouable) ; debt.increase sur une dette jamais ouverte ; chekhov.plant sans chekhov.fire nulle part — un fusil qu'on ne tire jamais est une promesse rompue au joueur.
  • Ergonomie et loyauté — ≥ 2 options visibles dans un état plausible ; pas d'option visible dépendant d'un actual non révélé (§5) ; onTimeout présent partout où deadlineTicks l'est ; teaches rempli.
  • Budget — Σ des severity éligibles par acte vs budget du directeur : si l'acte 2 propose 40 points là où le directeur en dépense 12, l'auteur écrit du contenu qui ne sortira jamais. Avertissement, mais affiché gros.

Sortie : un rapport par pack (erreurs / avertissements / info) avec un lien cliquable vers le nœud fautif. Même validateur en pre-commit et en CI.

11. Questions ouvertes

  1. Le joueur peut-il refuser de traiter un incident ? Une pile « en attente » qui grossit serait très juste métier — c'est exactement la vie d'un directeur de production — mais elle transforme le jeu en gestion de file d'attente et dilue la tension de chaque arbitrage. Piste : seuls les urgency: 'ambient' sont empilables, avec une pénalité de pourrissement. À trancher avec docs/04-game-loop.md.
  2. Quelle granularité pour truth.reveal ? La valeur exacte (38) est plus lisible et récompense l'attention ; le palier (« faible ») préserve le doute, et le doute est le carburant du jeu. Peut-être palier au premier indice, chiffre après confirmation par un second événement.
  3. Le hasard doit-il être re-tirable ? Une partie seedée garantit l'équité et permet des défis quotidiens partagés, mais un joueur qui perd sur un Outcome à 40 % accuse le jeu. Pity timer (deux mauvais tirages d'affilée sur un arc forcent le bon) ou cruauté assumée ? Lost in La Mancha est précisément l'histoire d'une série de tirages perdants.
  4. Combien d'arcs simultanés ? Trois arcs plus le bruit de fond, est-ce lisible ? La vraie question est mémorielle : si le joueur a oublié N1 quand N2 arrive douze jours plus tard, la traçabilité du débrief arrive trop tard pour être une leçon. Faut-il un « fil rouge » visible en UI, au risque de télégraphier la sanction ?
  5. Jusqu'où enrichir le WorldState ? Notre parade à la faible expressivité des prédicats est d'ajouter des champs dérivés. Mais un WorldState à 400 champs devient indocumentable pour les auteurs et chaque champ est du code à maintenir. Où est le plafond, et qui arbitre l'ajout ?
  6. Les aléas heureux existent-ils ? Le brouillard qui sauve un plan raté, la mairie qui prolonge l'autorisation. Indispensables au rythme (une partie uniquement descendante est déprimante et pédagogiquement fausse) mais ils diluent la leçon « toute économie se paie ». Quel ratio ? Et doivent-ils être eux aussi la conséquence différée d'une bonne décision — la symétrie propre ?
  7. Et en carrière multi-films ? Un Flag est scope: 'run'. Si le jeu enchaîne plusieurs films, la réputation doit traverser les parties (« vous avez viré un chef op sur le précédent, les agents le savent »). Faut-il un quatrième scope career et un journal inter-parties ? Ça change la nature du produit.
  8. Le débrief montre-t-il les chemins non pris ? « Si vous aviez payé Pascal Fabre à son tarif, le film coûtait 12 000 € de plus et finissait à l'heure. » Pédagogiquement imbattable, et faisable (rejouer le journal en substituant une décision — les effets typés le permettent). Mais c'est un contrefactuel : les aléas ambiants auraient été différents, et la promesse serait en partie mensongère. Assumer l'approximation, ou ne rien promettre ?