---
title: "Journal de bord ZEvent 2026 : d'un dossier vide à la LAN vivante"
subtitle: "40 heures, 107 commits, 19 PR, 4 mises en production. Le récit complet du chantier zevent.vfg.lol — des trois POC initiaux au mode rétrospective — avec les impasses, les quêtes secondaires et les bugs."
date: 2026-09-07
time: "02:45"
author: VFG / Claude Code
status: pub
tags: ["zevent", "journal", "retrospective", "three-js", "react", "docker", "vps", "prod", "audit", "2026-09-07"]
---

## TL;DR

Du **5 septembre 10 h 21** au **7 septembre 02 h 45**, `zevent.vfg.lol` est passé de trois preuves de concept qui ne se parlaient pas à une scène 3D de **341 escouades**, déployée pendant l'événement et corrigée pendant la nuit.

| | |
|---|---|
| Durée | ≈ 40 h |
| Commits / PR | 107 / 19 |
| Mises en production | 4 — v1.0.0, v1.1.0, v1.2.0, v1.2.1 |
| Lignes de code (front + serveur) | ≈ 21 500 |
| Sous-systèmes de scène | 12 |
| Constats d'audit traités | 16 + 24 |
| Cagnotte finale ZEvent 2026 | **32 891 874,15 €** |
| Record précédent (ZEvent 2025) | 16 658 660 € |
| Ratio | **197,4 %** |

Ce texte est reconstitué depuis la trace du dépôt : journal Git, changelog, audits, handoffs de session. Ce n'est pas un récit héroïque écrit après coup ; c'est ce que les fichiers racontent, remis dans l'ordre, avec les impasses.

## Prologue — trois POC et un dossier

Le 5 septembre à 10 h 21, le dépôt contient un `README` et des documents de projet. Autour, sur le disque, trois preuves de concept qui ne se parlent pas :

- **Kimi_Agent_Hub ZEvent temps réel** — un tableau de bord de données live ;
- **Kimi_Agent_Fusion Projet Sécurité** — une vue « Classe & Piste » ;
- **Kimi_Agent_Projet VJ 3D POC** — une scène 3D.

Chacun sait faire une chose. Aucun ne sait faire les autres. Le ZEvent commence dans deux jours.

La toute première décision du dépôt n'est pas graphique, elle est statistique. Commit `8f2ade4`, 10 h 49 : *« équilibrer le flux d'événements entre streamers d'échelles très différentes »*. Le problème est simple à énoncer et pénible à résoudre : si l'on affiche les dons dans l'ordre d'arrivée, les trois plus gros streamers noient les trois cents autres. Le *balancer* est né avant l'interface, et il a survécu à toutes les refontes qui ont suivi. C'est probablement le morceau de code le plus stable du projet.

## Jour 1, matin — trois plans pour une fusion

Entre 11 h 06 et 11 h 32, quelque chose d'inhabituel se produit : le dépôt accumule **trois plans de fusion concurrents** — un plan « Claude », un plan « Cursor », un plan « finale » — puis un document qui les compare. Les trois sont conservés, aucun n'est effacé.

C'est une méthode, pas un gâchis. Écrire trois fois le même plan avec trois angles différents fait apparaître ce sur quoi ils tombent d'accord : la source canonique du front doit être **une seule**, le backend doit exposer un **contrat** explicite, et les POC doivent être **archivés, jamais supprimés**. Ces trois décisions ont tenu jusqu'au bout.

Le mot d'ordre « archiver, jamais supprimer » a un coût — le dépôt pèse lourd — mais il a sauvé la mise deux fois dans les 48 heures qui ont suivi.

## Jour 1, midi — `zevent-stage` naît, et signe un contrat

14 h 35, commit `361a179` : l'app Fusion devient `zevent-stage`, le nouveau package racine du front. Deux minutes plus tard, `a079894` archive les trois POC. La bascule est nette : à partir de là, il n'y a plus qu'une seule source de vérité pour l'interface.

14 h 49, `52f5b46` : **le contrat d'API**. C'est le document qui a le plus servi. Il décrit ce que `zevent-dash` promet à `zevent-stage` : `/api/state`, `/api/feed`, `/api/goals`, `/api/title`, et un WebSocket `/ws`. Tout le reste du projet a été construit contre ce contrat, y compris les sous-systèmes 3D écrits deux jours plus tard par des chantiers parallèles qui ne se lisaient pas les uns les autres.

## Jour 1, après-midi — couper les relais publics

17 h 22. Quatre commits d'affilée qui font la même chose sous quatre angles. Le front allait chercher les données du ZEvent via des **relais publics** (`allorigins`, `jina`) pour contourner les CORS. Ça marche en démo. Ça ne marche pas un samedi soir de ZEvent, quand trois cents personnes rechargent la page.

`1e1f712` supprime `RELAYS` et `fetchZeData`. `1b2783b` écrit un vrai client backend : REST plus WebSocket, *backoff* de 1 s à 15 s, **type guards sur toutes les réponses**. `2eece31` branche `useZeventData` dessus : état initial en REST, mises à jour par WS, poll REST de 30 s en secours, et repli démo si le backend tombe.

Ce dernier point mérite d'être souligné, parce qu'il a servi pendant toute la suite du développement : **l'app a un mode démo autonome**. Backend éteint, elle génère un roster plausible et anime la scène. Sans ça, chaque itération graphique aurait exigé un backend vivant.

Dans la foulée, les **paliers** arrivent : un hook mutualisé qui interroge `/api/goals` par lots de 24 toutes les 45 secondes, une jauge sur les cartes, des événements « palier atteint » dans le flux, et un script de *prebuild* qui **fige un instantané** des paliers dans le bundle. Ce snapshot est ce qui permet au mode démo — et, comme on le verra à la toute fin, au mode rétrospective — de continuer à parler de paliers sans réseau.

## Jour 1, soir — Docker, audit, production

18 h 14, trois commits : le build de `zevent-stage` sort dans `zevent-dash/public/app`, le serveur sert l'app React sur `/` avec un *fallback* SPA et relègue le front vanilla sous `/legacy/`, et un **Dockerfile multi-étapes** construit le tout dans une image Node 20.

18 h 23, `68d6a8a` : audit sécurité et performance. **16 constats, 0 bloquant, 4 majeurs.** Le verdict est intéressant à relire :

> Aucun `innerHTML` / `dangerouslySetInnerHTML` / `eval`, type guards sur toutes les entrées réseau, cleanups d'effets quasi systématiques, teardown 3D exemplaire.

Les quatre majeurs sont plus instructifs que le satisfecit :

1. **M1** — un `href` de palier non validé côté dash. Le seul vecteur XSS réel du projet : une URL de don venue de l'amont, injectée telle quelle dans un lien.
2. **M2** — deux piles réseau cohabitent, `stage` et `dash`, et **ouvrent deux WebSockets par visiteur**. Personne ne l'avait vu parce que ça marchait.
3. **M3** — le hook de *hype* reconnectait `tmi.js` à chaque changement d'ordre de la liste de chaînes. Le correctif est d'une simplicité désarmante : trier par login pour la clé d'effet, au lieu de trier par viewers. L'ordre d'affichage n'a plus à provoquer une reconnexion.
4. **M4** — une protection d'empreinte côté client qui reposait sur **un commentaire inexact**. Le code ne faisait pas ce que le commentaire affirmait. C'est le genre de bug qu'aucun test ne trouve, parce que le test aurait été écrit d'après le commentaire.

Les correctifs tombent entre 19 h 25 et 19 h 33. À **20 h 05**, commit `62314bb` : **v1.0.0 en production sur zevent.vfg.lol**. Dix heures après le premier commit.

## Intermède — deux quêtes secondaires

Un projet réel n'avance jamais en ligne droite. Deux incidents sans rapport direct avec le ZEvent occupent une partie de la journée.

### `lucien.best` était mort depuis deux mois

En allant configurer Caddy pour `zevent.vfg.lol`, on découvre que les *vhosts* d'un autre site du serveur, `lucien.best`, ont disparu du Caddyfile **depuis le 8 juillet**. Le site répondait par une erreur TLS interne (*no peer certificate*) depuis deux mois, sans que personne le remarque.

Le diagnostic laisse une leçon opérationnelle notée dans le handoff : la configuration avait `admin off`, donc l'API d'administration de Caddy sur le port 2019 était morte, donc `systemctl reload caddy` ne rechargeait rien. Il fallait un `restart`. Et une consigne anti-régression : **ne jamais réduire le Caddyfile aux seuls imports de la flotte**.

Cet épisode explique une règle qui traverse tous les runbooks du dépôt, y compris ceux d'aujourd'hui : **ne pas toucher à Caddy**. Le périmètre d'un déploiement ZEvent, c'est le service Docker `zevent`, rien d'autre.

### `/kimi-3d` affichait `/kimi-hub`

Les trois POC sont archivés sous `zevent.vfg.lol/kimi-*`. En production, `/kimi-3d/` sert le contenu de `/kimi-hub/`. Les archives locales sont pourtant correctes.

Le bug a résisté assez longtemps pour mériter son propre handoff de diagnostic. Les hypothèses passées en revue : copie PowerShell avec des chemins Unicode, `dist` écrasé, mauvais dossier dans `public/`, image Docker périmée. Correction à 13 h 17, puis réalignement de l'archive `kimi-3d` sur la fusion — **instantané identique octet pour octet** — parce qu'après un bug pareil, on ne se contente pas de « ça a l'air bon ».

## Nuit 1 — l'audit qui fait mal

**Audit complet du workspace : 14 constats de sécurité, 10 constats d'architecture**, suivi d'une remédiation P0/P1. Trois titres suffisent à comprendre le ton :

- **arrêt à distance** — une route permettait d'éteindre le service ;
- **empreinte falsifiable** — le fameux `fp` du M4, cette fois traité au fond ;
- **bans caducs** — des bannissements de chat qui n'expiraient pas comme annoncé.

Aucun de ces trois problèmes n'était visible depuis l'interface. Tous auraient pu se réveiller un samedi soir.

## Jour 2, matin — « la LAN vivante »

6 septembre, 10 h 21. Commit `b2cb698` : **maquette v3, « la LAN vivante »**. C'est le moment où le projet cesse d'être un tableau de bord.

L'idée : ne pas afficher un classement, mais **une LAN**. Chaque streamer a un poste, un volcan dont la hauteur suit sa cagnotte, une pilule de nom. Au centre, une montagne d'or : la cagnotte globale. Le ciel est plein d'étoiles. Les dons tombent du ciel en comètes.

Huit minutes plus tard, **découpage en 13 tâches atomiques** (U0 à U7 pour la scène, plus les vagues d'intégration). Le principe de U0 mérite d'être cité, parce que c'est lui qui a rendu le parallélisme possible :

> Déposer un `scene/<name>.ts` qui appelle `registerSubsystem` — chargé automatiquement, sans modifier `UniverseScene` / `registry` / `textures`.

Un registre, un glob d'import, un contrat `Subsystem { update, onEvent, dispose }`. Chaque chantier écrit son fichier et ne touche à rien d'autre. Le fichier `registry.ts` porte un commentaire explicite : *« livré par U0, JAMAIS modifié ensuite »*.

Cette contrainte a un prix, visible dans le code : `rts.ts` contient une longue note d'écart expliquant qu'il ne peut pas déplacer les escouades, parce que `characters.ts` réécrit leur position à chaque frame et qu'il n'a pas le droit de le modifier. Le ring de duel s'est donc contenté d'une **surbrillance**. On y reviendra — c'est exactement ce qui a été levé la dernière nuit.

## Jour 2 — la maquette v4, « Universe RTS »

10 h 58 : maquette v4. Les postes deviennent des **escouades** — un personnage, un vaisseau-bureau qui le suit avec un retard élastique, un volcan qui garde sa place à côté. Les comètes de dons deviennent des rubans arc-en-ciel à trois calibres. Les **buffs** apparaissent : *En feu*, *Gros don*, *Hype max*, *Dépassement*, *Palier imminent*, *Tous paliers*.

Puis, à 11 h 09, un commit qui dit tout du travail de lisibilité : *« écarter l'arc LAN et rendre les pilules de pseudo lisibles »*. Le placement en arc régulier empilait les streamers. La solution retenue vit toujours dans `layout.ts` : **cinq bras irréguliers** autour du lac, avec des rayons et des ouvertures angulaires différents, plus un *jitter* stable dérivé d'un hash FNV-1a du login. Stable, parce qu'un streamer doit se retrouver au même endroit d'une session à l'autre.

11 h 57 : *« mass-call freeze, bigger avatars, wander, Sims plumbob »*. Le *mass-call freeze* est un vrai bug de charge — un appel groupé déclenchait 337 animations simultanées et gelait la page. Le correctif est un **budget** : au plus 12 saltos par fenêtre de 0,35 s. Ce motif (budget par fenêtre, plafond global, dégradation propre) revient partout dans la suite du projet.

12 h 00 : la maquette monolithique `v4.html` est découpée en modules ES *« sans changement de comportement »*, et les glyphes Matrix sont mutualisés. Refactoring pur, annoncé comme tel.

## Jour 2, après-midi — v4.1 et le piège de mesure

13 h 39 : **douze corrections d'un coup**. Sablier, ciel dense, avatar déplacé dans l'écran du poste, quatre courses au lieu d'une.

Le commit suivant est celui qu'il faut garder : *« consigner la vague v4.1 — mesures, défauts d'intégration et **piège de mesure** »*. Quelqu'un a pris le temps d'écrire non seulement ce qui a été corrigé, mais **comment la mesure elle-même avait induit en erreur**.

On en trouve la trace directe dans le code, dans `sky.ts` :

> `sizeAttenuation: false` garde des points de taille constante à l'écran : épaissir la voûte ne les espace donc pas.

Autrement dit : augmenter la densité d'étoiles ne rendait pas le ciel plus riche, il le rendait **plus opaque**. La correction n'est pas de baisser la densité, c'est de faire décroître taille et opacité *avec* la densité.

Même famille de piège, dans `layout.ts`, avec un commentaire d'une honnêteté rare :

> Sans ce garde-fou, `pct` part à 100+ dès que la cagnotte dépasse largement le palier annoncé — cas nominal du mode démo. Les consommateurs s'en servent comme facteur d'échelle : un `pct` de 106 donnait des disques de lave de 65 u et des lueurs de 212 u, soit un voile ambre opaque sur toute la scène.

Un pourcentage non borné utilisé comme facteur d'échelle. Le symptôme (« l'écran est orange ») n'avait aucun rapport visible avec la cause (« la démo gonfle les dons face à des paliers figés »). Ce bug a une descendance : il est réapparu la dernière nuit, sous une autre forme, et il a fallu le tuer une seconde fois.

## Jour 2, soir — la parité React

Deux vagues, deux PR.

**Vague 1** : ciel dense, comètes, pluie de glyphes, fontaine avec jalons réels et sablier, escouades avec buffs et vrais paliers, données branchées (rythme, fin d'événement, record de l'édition précédente), HUD v4, réglages de scène.

**Vague 2** : mode RTS complet — sélection au sol, duels, ring, minimap, journal de partie.

Puis la série de corrections de fin de soirée, dont deux disent quelque chose d'important sur le rapport au retour utilisateur :

- 22 h 37 : *« retire les avatars emoji »*. Les têtes emoji sont supprimées, l'avatar vit désormais **dans l'écran du poste**. Le code garde la trace du raisonnement : `characters.ts` conserve le sprite de tête, invisible, plutôt que d'arracher toute la structure.
- 22 h 44 : *« volcans plus petits + bouton pour les masquer »*. Le réglage par défaut passe à 0,55, et un bouton apparaît dans la barre d'outils. Quand un élément graphique gêne, on ne se contente pas de le réduire : on donne le contrôle.

## Nuit 2 — la discipline du déploiement

Le passage en production de la v1.1.0 est, dans le dépôt, l'épisode le plus documenté : un runbook complet, un manifeste, une matrice de parité, un rapport de portes de certification, un inventaire de documentation.

Le runbook contient une *gate* qui refuse le build si `HEAD` n'est pas exactement le tip de la PR :

```bash
test "$(git rev-parse HEAD)" = "$TIP" \
  && test "$(git rev-parse 'HEAD^{tree}')" = "$TREE" \
  && git merge-base --is-ancestor "$BASE" HEAD \
  || { echo "REFUS: HEAD/tree ≠ tip PR"; exit 1; }
```

Et une note qui vaut tous les post-mortems :

> Digest plan `2cb9562b…` — **ABSENT** sur le VPS — ne pas l'utiliser.

Le plan de déploiement citait un digest de rollback qui **n'existait pas sur la machine**. Quelqu'un a vérifié avant d'en avoir besoin. C'est la différence entre un plan de rollback et un rollback.

## Nuit 3 — six phrases, six chantiers

7 septembre, une heure du matin. La demande tient en une phrase, mais elle contient six exigences : que les streamers se déplacent et se regroupent, se rejoignent ; beaucoup plus d'animations de buff en temps réel pour suivre le décompte ; que la montagne de sous soit toujours visible ; qu'on comprenne que les étoiles sont des streamers ; qu'on puisse repérer les derniers streamers avec encore des paliers.

Chacune de ces phrases pointe un endroit précis où la scène **ment ou se tait**.

### 1. Ils bougeaient, mais chacun de son côté

L'errance existait : ±16 unités autour du poste, et une dérive lente vers le lac plafonnée à 30 % du trajet. Le problème est que cette dérive était **individuelle**. Trois cent quarante personnes qui avancent chacune un peu vers le centre, ça ne fait pas un rassemblement : ça fait une file.

Deux réponses, à deux échelles.

**À l'échelle ambiante — les clans.** La dérive ne vise plus le lac mais le **centre de gravité des rangs voisins**, par groupes de huit, ce centre étant lui-même tiré de 18 % vers le lac. La LAN se resserre en grappes lisibles au lieu de rester alignée sur cinq bras réguliers. Le centre de clan est calculé une seule fois par reconstruction, à partir des positions de départ : stable, donc pas de boucle de rétroaction avec les positions courantes.

**À l'échelle événementielle — les rassemblements.** Un nouveau sous-système décide **qui** se rejoint, **où** et **pourquoi**, et le rend lisible : un cercle au sol, une bannière qui nomme la scène, un compte à rebours.

| Scène | Déclencheur | Ce qu'on voit |
|---|---|---|
| Ruée vers la montagne | le jalon global est à moins de 8 % du pas | le haut du classement descend au pied de la montagne |
| Sur le ring | duo envoyé depuis la bagarre ou Maj+clic | deux rivaux se rejoignent à mi-chemin |
| Cercle des paliers | au moins 3 paliers imminents | qui va faire tomber le sien, d'un coup d'œil |
| Le podium | roster suffisant | les meneurs se retrouvent |

Pour que ça fonctionne, `characters.ts` a reçu une couche d'**ordres de marche** : une place attribuée sur un cercle qui tourne lentement, un freinage à l'approche, et un retour au pas de course une fois l'ordre échu. Pendant un ordre, l'errance et la dérive sont suspendues.

Et surtout : **le ring déplace enfin pour de vrai**. La note d'écart de `rts.ts`, écrite la veille pour expliquer pourquoi c'était impossible, a été remplacée par le code qui le fait. Ce n'était pas une limite technique, c'était une limite d'organisation — et elle avait été honnêtement documentée comme telle, ce qui a rendu sa levée triviale.

### 2. Le seul vrai mensonge de la scène

Le ciel comptait trente étoiles nommées, portant des pseudonymes tirés d'un vivier codé en dur : *Trombine*, *PainPerdu*, *Gravillon*, *MousseAuChoc*… La justification, dans le code, était franche : *« l'API ZEvent ne publie pas l'identité des donateurs, on habille les étoiles avec des pseudos de tchat plausibles »*. Des données décoratives inventées, présentées exactement comme les vraies.

La correction fait d'une pierre deux coups. **Une étoile = un streamer.** Chaque étoile est suspendue à la verticale de son escouade et **reliée à elle par un filament** — un seul `LineSegments` pour les 56 liens. Sa taille suit la cagnotte sur une échelle logarithmique, pour que le premier n'écrase pas le dernier. Sa couleur dit l'état de ses paliers : vert *en route*, ambre *proche*, corail *imminent*, or *tous franchis*.

Et la comète de don part désormais de **l'étoile du streamer qui reçoit**, pour tomber sur son poste. On voit qui reçoit, et combien. Le faux disparaît, le sens apparaît, et l'exigence — « on doit comprendre » — est satisfaite par la géométrie plutôt que par une légende.

### 3. « Toujours » est un mot exigeant

Trois réponses à trois échelles pour la montagne de sous.

**Dans le HUD** — un bandeau permanent et collant : total, jauge vers le prochain jalon, ce qu'il reste à gravir, **le temps estimé au rythme courant**, le pourcentage du record, et le compte à rebours de fin de collecte avec sa propre barre. Il ne dépend pas de la caméra.

**Au bord de l'écran** — une **boussole**. Quand la montagne sort du cadre, une flèche apparaît au bord, dans sa direction ; un clic recadre ; elle disparaît dès qu'elle est de nouveau visible. Le calcul mérite un mot : le projeté d'un point situé **derrière** la caméra est miroité, donc il faut un test de demi-espace (produit scalaire avec la direction de la caméra) avant de conclure quoi que ce soit sur les coordonnées écran. Sans ça, la flèche pointe pile à l'opposé.

**Dans la scène** — trois gradins, qui donnent à la silhouette une lecture de *pile de sous* plutôt que de simple cône, un **phare de sommet** rendu sans test de profondeur, et une colonne de lumière. La montagne reste repérable derrière la forêt de volcans.

### 4. Le plafond était au mauvais endroit

Il y avait un bug, et il était structurel.

Les buffs d'état — *Palier imminent*, *Hype max*, *Tous paliers* — étaient **coupés en LAN dense**, c'est-à-dire au-delà de 48 streamers. Or il y en a 340. Ces buffs n'étaient donc **jamais** affichés en production. La garde avait été écrite pour une bonne raison : chaque pilule créait sa propre texture canvas de 512 × 128, soit 256 Ko de mémoire GPU par buff. Quatre-vingt-dix buffs, vingt-trois mégaoctets.

La vraie correction n'était pas de relever le plafond mais de **partager les textures par clé** : cinq textures statiques au lieu d'une par instance. Le plafond est devenu presque gratuit, et la garde de densité a pu sauter.

Restait un second piège, plus subtil. Les buffs d'état ont une durée **infinie** — ils suivent la donnée, pas un minuteur. Sous un plafond unique, ils occupaient toute la place et les buffs d'événement — *Gros don*, *En feu*, *Dépassement*, ceux qui font justement vivre la scène — n'avaient plus de sprite disponible. D'où **deux budgets séparés**, plus une règle supplémentaire : *Tous paliers*, le moins actionnable de tous, cède la place dès que la moitié du budget d'état est consommée.

Pour le « temps réel pour suivre le décompte », les pilules d'état sont devenues **dynamiques** : « Palier imminent · 1 240 € », réécrit deux fois par seconde. Deux fois, pas soixante — un canvas réécrit à chaque frame pour afficher un montant, c'est du gaspillage pur.

### 5. Repérer les derniers streamers avec encore des paliers

C'est la question de fin de collecte. Elle n'est pas « qui mène ? » mais « qui a encore quelque chose à faire tomber ? ».

Un sous-système allume des **colonnes de lumière** au-dessus des dix-huit streamers les plus proches d'un palier — moins de paliers restants d'abord, puis, à égalité, moins d'euros manquants. Corail, ambre ou vert selon l'urgence. En fin de collecte, ce sont littéralement les derniers phares allumés de la LAN.

En doublure, un panneau « Derniers paliers » : le compteur global, le même classement, et un clic qui cadre la caméra sur l'escouade. La liste et le relief racontent exactement la même histoire — c'est un choix, pas une coïncidence : les deux lisent le même module.

### 6. Le bug qu'on n'avait pas demandé de corriger

En construisant le panneau, une anomalie saute aux yeux : toutes les lignes affichent **« plus que 0 € »**.

`/api/goals` renvoie l'état des paliers **au moment du relevé** : `raised`, `next`, `prevAmount`, `reachedCount`. Le relevé tourne toutes les 45 secondes. Entre deux appels, la cagnotte avance — et en mode démo, où les dons sont générés face à un instantané figé, elle avance énormément. Le palier annoncé comme « suivant » était donc souvent **déjà franchi**. D'où un « reste 0 € » et une barre bloquée à 100 %.

C'est le fantôme du `pct` de 106 de la veille, revenu par une autre porte.

La correction est un module unique qui **recale le relevé sur la cagnotte courante** en consommant la liste `upcoming` — la seule source qui dise ce qui vient *après* le palier relevé. Il gère explicitement le cas où cette liste, plafonnée à douze entrées côté serveur, est épuisée : on sait alors qu'il reste du travail sans savoir combien, et on l'annonce ainsi — « montant à relever » — plutôt que de mentir avec un zéro.

Ce module est utilisé par la scène, par la fiche streamer, par la bagarre et par le panneau paliers. **Ils ne peuvent plus diverger.** C'était d'ailleurs déjà le diagnostic d'un commentaire écrit la veille : *« quatre copies divergentes étaient justement le bug à corriger »*.

Dans la même passe, deux petites choses qui grattaient : la fiche streamer annonçait « Tous paliers franchis » **et** listait juste en dessous des paliers suivants déjà dépassés ; et un cliquer-glisser pour tourner la caméra **sélectionnait** le streamer sous le curseur, parce que le navigateur émet un `click` à la fin d'un glissement. Six pixels de tolérance ont réglé l'affaire.

## 02 h 36 — le déploiement, et la mauvaise nouvelle

L'image `1.2.0` est construite, testée hors compose, promue, et le service recréé. Contrôle public : 200 partout, bundle identique au build local.

Puis vient l'inspection de `/api/state`, et avec elle une phrase qui change tout :

```json
{"total":32891874.15,"viewers":0,"liveCount":0,"ratePerMin":0,
 "eventEnd":1788739200000,"count":340}
```

**L'événement est terminé.** Tout le monde est `online: false`.

Or toute l'animation de la scène — mouvement, errance, rassemblements, faisceaux, buffs d'état, écrans allumés — était conditionnée à `online`. La page qui venait d'être déployée était une **nature morte**. Précisément au moment où l'on vient revoir les chiffres figés.

C'est le genre de régression qu'aucun test ne trouve, parce qu'elle n'est pas dans le code : elle est dans une hypothèse implicite. *« Un streamer qui compte est un streamer en direct. »* Vraie pendant 72 heures. Fausse pour toujours ensuite.

## 02 h 45 — le mode rétrospective

La correction introduit une distinction que le code n'avait jamais faite : `online` (est-il en direct ?) et **`present`** (dois-je l'animer ?).

```ts
export function estRetrospective(data: SourceRetro): boolean {
  const fin = data.getEventEnd?.() ?? null
  if (fin !== null && fin > 0 && Date.now() > fin) return true
  // roster peuplé, plus un seul live : même signal
  return data.getLiveCount() === 0 && data.getStreamers().length > 0
}
```

Deux signaux plutôt qu'un. Le second couvre le cas où l'amont ne publie pas de date de fin — et reste faux au démarrage, quand le roster est encore vide.

À partir de là, `characters.ts` anime selon `present`, `stations.ts` allume les écrans selon `present`, les faisceaux et les rassemblements prennent le roster figé au lieu d'une liste vide. Et le HUD **le dit** : badge **TERMINÉ**, « il a manqué 8 126 € pour le jalon suivant » au lieu de « reste 8 126 € », bloc « Collecte — terminée », et la ligne viewers/lives/rythme — toute à zéro, donc muette — cède la place à « roster figé de l'édition ».

Le principe qui a guidé l'arbitrage : **ré-animer, mais ne jamais laisser croire au direct**. Une archive vivante reste une archive, et elle doit le dire.

Vérification sur les données réelles, avant redéploiement :

- **32 891 874 €**, badge TERMINÉ ;
- **197,4 % du record ZEvent 2025** ;
- **341 postes**, roster figé ;
- **69 streamers avec 230 paliers non franchis**, avec leurs vrais libellés — *« je rajoute un NEZ ROUGE pour le stream perm »*, plus que 78 € ; *« C'est vous qui choisissez ! »*, plus que 160 € ; *« Je vous dévoile ma date de naissance »*, plus que 436 € ;
- scènes de rassemblement actives, faisceaux allumés.

`1.2.1` est construite, `pre-1.2.1` taguée pour le rollback, le service recréé. Conteneur *healthy*, bundle en production identique au build local.

## Ce que le dépôt a appris

**Les commentaires mentent, et c'est un bug de sécurité.** Le constat M4 du premier audit — une protection qui reposait sur un commentaire inexact — est le plus instructif du lot. Aucun test ne l'aurait attrapé : le test aurait été écrit d'après le commentaire.

**Documenter une limite d'organisation, c'est préparer sa levée.** La note d'écart de `rts.ts` expliquait précisément pourquoi le ring ne pouvait pas déplacer les escouades. Quand la contrainte est tombée, il a suffi de suivre la note. Une limite non documentée serait devenue une limite définitive.

**Un pourcentage n'est pas un facteur d'échelle.** Deux fois le même bug, à vingt-quatre heures d'écart : un ratio non borné utilisé pour dimensionner des objets 3D, puis un ratio calculé contre une donnée périmée. Le symptôme n'a jamais ressemblé à la cause.

**Une garde de performance qui supprime une fonctionnalité dans 100 % des cas réels n'est pas une garde, c'est une suppression.** Les buffs d'état étaient coupés à 48 streamers pour une raison de mémoire de texture. Il y en a 340. La vraie réponse était de partager les textures, pas de cacher les buffs.

**Les ressources infinies mangent les ressources éphémères.** Séparer les budgets d'état et d'événement était la seule façon de garantir qu'un gros don ait toujours un sprite disponible pour se montrer.

**Vérifier le rollback avant d'en avoir besoin.** Le digest de secours du plan v1.1.0 n'existait pas sur la machine. Le découvrir un dimanche à 3 h du matin aurait coûté autrement plus cher.

**Une hypothèse implicite survit à toutes les relectures.** « Un streamer qui compte est un streamer en direct » n'était écrit nulle part. Elle a survécu à deux audits, treize tâches, quatre revues, et n'est tombée qu'au contact du seul cas qu'elle ne couvrait pas : le lendemain.

## Épilogue

Le dossier de départ contenait trois preuves de concept qui ne se parlaient pas. Quarante heures plus tard, [zevent.vfg.lol](https://zevent.vfg.lol) sert une LAN de 341 escouades où chaque streamer a son étoile, son volcan, son poste et ses paliers, où les groupes se forment et se défont, où une montagne d'or mesurée en euros réels reste repérable de n'importe quel angle, et où les derniers phares encore allumés désignent ceux qui avaient encore quelque chose à promettre.

L'événement est terminé. Les données sont figées. La LAN, elle, bouge encore — et elle dit clairement qu'elle se souvient.