Événements — aléas, conséquences, graphe narratif
Fiche d'entité. Respecte
README.md:ContentBasepour les définitions,InstanceBasepour le runtime,Tick= une demi-journée (tick = 2 × jour + 0 matin | 1 après-midi), euros entiers_eur, probabilités0..1_p, stats 0–100,Ratedpour 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.
| Principe | Ce que ça impose au modèle |
|---|---|
| L'aléa est la matière première du plaisir | plus grosse masse éditoriale du jeu ⇒ un format d'auteur confortable, pas des if dans le code |
| L'aléa est le vecteur pédagogique | chaque 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 posteriori | traçabilité causale : « ta décision du jour 3 → cet incident du jour 24 » |
| Le jeu doit rester rejouable | pas 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.
GameEvent — le fait | IncidentDef — l'aléa scénarisé | |
|---|---|---|
| Nature | une ligne immuable du journal | du contenu écrit par un auteur |
| Temps | le passé — c'est arrivé | le futur possible — ça peut arriver |
| Auteur | le moteur | le backoffice, versionné en git |
| Contrat | ni Def ni Instance : le journal dont l'Instance dérive | une Def (kind: 'event') |
| Exemple | casting.comedien.engage, tournage.plan.tourne, meteo.jour.pluvieux | « Le chef op met trois heures à éclairer » |
| Par partie | des milliers | quelques 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égorie | Enseigne | Exemples |
|---|---|---|
| météo | le plan B décor, l'assurance intempéries | pluie 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 |
| humain | l'équipe est une matière vivante | le 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 |
| technique | la redondance, le rapport LTC, le DIT | la 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é |
| logistique | le film est d'abord un problème de transport | camion 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égal | autorisation, déclaration, médecine du travail | la 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 |
| financier | plan de financement ≠ trésorerie | une 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 |
| artistique | l'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 |
| externe | on ne contrôle pas le monde | grè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ère | JS en sandbox | mini-DSL texte | arbre JSON |
|---|---|---|---|
| Expressivité | totale | bonne | volontairement bornée |
| Éditable dans une UI de graphe | non | à moitié | oui, nativement (un widget par nœud) |
| Diff git | mauvais | correct | excellent (structurel) |
| Analysable statiquement (§10) | impossible | difficile | trivial — on parcourt l'arbre |
| Déterminisme au replay | fragile | bon | garanti |
| Sécurité (contenu venu de PocketBase) | exécution arbitraire | sûr | sûr |
| Coût d'écriture | faible | faible | moyen, 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 effet — money.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 possible —
money.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écanique | Sémantique | Quand |
|---|---|---|
Flag | « ce fait est vrai » — ne déclenche rien seul | rendre un aléa éligible plus tard (le chef op sous-payé) |
Debt | « la pression monte », un compteur à seuil | quand 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) | |
|---|---|---|
| Volume | 6 à 10 fils par scénario, écrits à la main | 150 à 300 incidents indépendants |
| Structure | nœuds + arêtes | pool plat, aucune arête |
| Déclenchement | l'arc avance de nœud en nœud | conditions + poids, tirage du directeur |
| Rôle | la dramaturgie, la leçon, la montée en acte 3 | la texture, la friction, l'imprévu |
| Exemples | le chef op au rabais ; la star imposée ; le décor perdu | la pluie, le camion, la cantine |
| Tension (§9) | prioritaire — un arc n'attend pas | comble 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 jouer | Ce que ça fait |
|---|---|
| État de départ arbitraire | on 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-Carlo | 10 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,optionIdpointe sur quelque chose. La plus bête, la plus rentable. - Satisfiabilité — l'arbre étant analysable, on détecte les contradictions :
phase == 'preparation'dans un defphases: ['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.increasesur une dette jamais ouverte ;chekhov.plantsanschekhov.firenulle 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
actualnon révélé (§5) ;onTimeoutprésent partout oùdeadlineTicksl'est ;teachesrempli. - 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
- 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 avecdocs/04-game-loop.md. - 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. - 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. - 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 ?
- Jusqu'où enrichir le
WorldState? Notre parade à la faible expressivité des prédicats est d'ajouter des champs dérivés. Mais unWorldStateà 400 champs devient indocumentable pour les auteurs et chaque champ est du code à maintenir. Où est le plafond, et qui arbitre l'ajout ? - 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 ?
- Et en carrière multi-films ? Un
Flagestscope: '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 scopecareeret un journal inter-parties ? Ça change la nature du produit. - 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 ?