Aller au contenu principal

01 — Architecture technique

TL;DR des décisions

QuestionDécisionPourquoi en une ligne
Stack de départtemplate vite-react-pocketbasedéjà React 19 + TS + Tailwind 4 + @gabvdl/ui + RxDB + scripts de déploiement
« Plutôt du NoSQL ? »PocketBase, en l'utilisant comme un document store (colonnes json + schémas Zod)on veut du document, pas un serveur de plus
Source de vérité du contenudes fichiers JSON versionnés en git, PB est la surface d'éditionun graphe narratif se relit, se diffe, se git revert
État de partiejournal d'événements append-only + snapshotscf. 02-event-sourcing.md — oui, l'event sourcing est le bon modèle
Moteur de jeupackage TypeScript pur, sans React, sans réseau, déterministepermet de simuler 10 000 parties en CI pour équilibrer
Jeu et éditeurune seule application, deux routes, un seul modèle de donnéesl'éditeur doit pouvoir lancer une partie de test en un clic
Local-firstoui, RxDB en local, PB en synchroon joue dans le train, et une partie est un objet personnel

1. Les trois domaines de données

C'est la décomposition qui commande tout le reste. Ils n'ont ni le même rythme d'écriture, ni le même cycle de vie, ni le même propriétaire, donc ils ne doivent pas être stockés de la même façon.

┌─────────────────────────────────────────────────────────────────┐
│ A. CATALOGUE DE CONTENU écrit par l'auteur, lu par tous │
│ scénarios, personnes, éléments, scènes, incidents, graphe │
│ ~ quelques milliers de documents, lecture massive, │
│ écriture rare, versionné, doit être diffable │
│ → JSON en git (vérité) ⇄ PocketBase (édition) → bundle │
├─────────────────────────────────────────────────────────────────┤
│ B. ÉTAT DE PARTIE écrit par le moteur, lu par 1 │
│ journal d'événements + snapshots │
│ ~ quelques milliers d'événements par partie, append-only │
│ → RxDB local (autorité) → PocketBase (sauvegarde/synchro) │
├─────────────────────────────────────────────────────────────────┤
│ C. COMPTES & MÉTA PocketBase natif │
│ users, liste des sauvegardes, télémétrie d'équilibrage │
└─────────────────────────────────────────────────────────────────┘

Les erreurs classiques que cette séparation évite : mettre l'état de partie dans des tables relationnelles (ingérable), et mettre le contenu uniquement en base (ingérable à relire et à versionner).


2. La question NoSQL, tranchée

« intuitivement je pense qu'une base NoSQL serait mieux pour stocker des éléments de gameplay arbitraires »

L'intuition sur la forme est juste, la conclusion sur l'outil ne l'est pas.

Ce qu'il faut vraiment, c'est un modèle documentaire : des objets hétérogènes, imbriqués, polymorphes, dont le schéma bouge à chaque semaine de design. Pas une base NoSQL en tant que serveur.

Ce que PocketBase sait déjà faire

  • Des colonnes de type json, requêtables via SQLite JSON1 (payload.stats.metier > 60 fonctionne dans un filtre).
  • L'auth, les règles d'accès par collection, le stockage de fichiers (photos de tournage, PDF de scénario, audio), le temps réel par abonnement, les migrations, et une interface admin gratuite.
  • Un seul binaire, un seul volume, déjà déployé dix fois dans le lab.

Le coût d'un vrai NoSQL (Mongo, Couch, Surreal)

  • Un service de plus à héberger, sauvegarder, mettre à jour.
  • Plus d'auth, plus de règles, plus de stockage de fichiers, plus d'admin — donc il faudrait quand même PocketBase à côté, ou tout réécrire.
  • Le volume ne le justifie pas : on parle de milliers de documents, pas de millions. SQLite est très largement au-dessus du besoin.

La forme retenue

// collection PocketBase `content`
{
id: string // ULID
pack: string // relation -> packs (un scénario = un pack)
kind: EntityKind // 'person' | 'scene' | 'incident' | ...
slug: string // unique par (pack, kind)
payload: object // ← le document, validé par un schéma Zod par `kind`
schema_version: number
status: 'draft' | 'review' | 'ready'
updated: string
}

Une seule collection polymorphe, indexée sur (pack, kind, slug). Le payload est un document libre côté base et strictement typé côté code par un schéma Zod par kind. On obtient la souplesse du document et la rigueur du typage, sans serveur supplémentaire.

Le vrai garde-fou n'est pas la base, c'est le schéma applicatif. Un schéma Zod par kind, partagé par le moteur, l'éditeur et un content:lint exécuté en CI. C'est ça qui empêche le contenu de pourrir — pas le choix Mongo/SQLite.

Le contenu vit en git, pas en base

Point important et peu intuitif : la base n'est pas la source de vérité du contenu.

content/<pack>/persons/*.json ← la vérité, versionnée, relue en PR
content/<pack>/scenes/*.json
content/<pack>/incidents/*.json
content/<pack>/graph.json

│ npm run content:import (git → PocketBase, pour éditer)

PocketBase ← l'éditeur écrit ici, confortablement

│ npm run content:export (PocketBase → git, un fichier par entité,
▼ clés triées, diff lisible)
commit

│ npm run content:build (validation Zod + résolution des refs
▼ + bundle figé + hash)
dist/content-<hash>.json ← ce que le jeu charge

Pourquoi c'est le bon choix ici :

  • Un fil narratif est du code. On veut le relire, le comparer, le revenir en arrière, le brancher. Un git diff sur incidents/chef-op-au-rabais.json est infiniment plus utile qu'un historique de table.
  • Le contenu est immuable au runtime : le bundle est figé et hashé, donc une partie sauvegardée sait exactement contre quelle version de contenu elle a été jouée — indispensable quand l'état est dérivé d'un journal (cf. §5).
  • Ça permet de jouer hors ligne : le contenu est un asset statique.
  • Le jour où quelqu'un d'autre écrit du contenu, une PR est déjà le bon processus.

Le prix à payer : un aller-retour import/export à discipliner, et un conflit possible si on édite des deux côtés. Mitigation : l'export est déterministe (clés triées, formatage stable) et content:export refuse de tourner sur un working tree sale.


3. Découpage du code

Le point non négociable : le moteur ne connaît ni React, ni PocketBase, ni le réseau, ni l'horloge, ni le hasard.

lost-in-production/
├─ packages/
│ ├─ schema/ Zod : entités de contenu, événements, effets, conditions
│ │ → la seule source de vérité des types, importée partout
│ ├─ engine/ TypeScript pur, zéro dépendance runtime
│ │ ├─ reduce.ts (State, GameEvent) => State ← pur
│ │ ├─ resolve/ résolution d'un plan, d'une journée
│ │ ├─ director/ le directeur d'aléas
│ │ ├─ rules/ évaluateur de conditions, applicateur d'effets
│ │ ├─ rng.ts PRNG seedé, déterministe
│ │ └─ sim/ harnais de simulation headless
│ └─ ui/ composants partagés jeu ↔ éditeur (le stabilo, le calendrier,
│ la fiche personne) — au-dessus de @gabvdl/ui
├─ apps/
│ ├─ game/ l'écran de bureau, les fenêtres, la partie
│ └─ editor/ le backoffice (peut n'être qu'une route de `game` au début)
├─ content/ les packs JSON versionnés
├─ pocketbase/ migrations + hooks
└─ docs/ ← vous êtes ici

Ce que la pureté du moteur achète, concrètement :

  1. La simulation headless. npm run sim -- --seeds 10000 --pack demo fait tourner dix mille parties avec une IA basique et sort les distributions : combien finissent le film, à quel dépassement médian, quels incidents ne se déclenchent jamais, lesquels sont fatals dans 90 % des cas. C'est le seul moyen d'équilibrer un jeu de gestion — sans ça, on équilibre à l'oreille et on se trompe.
  2. Les tests. Un test = un état de départ + une liste d'événements + une assertion sur l'état final. Rapide, sans DOM, sans base.
  3. Le rejeu. Même journal + même contenu + même seed = même partie, bit pour bit. C'est la condition de l'event sourcing et de la fonction « et si j'avais choisi l'autre option ? ».
  4. La validation de contenu. Le content:lint réutilise l'évaluateur de conditions du moteur pour détecter les nœuds injouables.

Corollaires à faire respecter par lint (no-restricted-imports sur packages/engine) :

  • pas de Math.random() — le hasard passe par rng(seed, …) ;
  • pas de Date.now() — le temps est un champ de l'état ;
  • pas de fetch, pas d'accès PocketBase, pas d'import React.

4. Le local-first

Une partie appartient au joueur et doit tourner sans réseau.

  • RxDB (déjà dans le template) tient le journal d'événements et le snapshot courant en IndexedDB. C'est l'autorité pendant qu'on joue.
  • PocketBase reçoit les événements par lots, pour la sauvegarde et le multi-appareil. La synchro est triviale parce que le journal est append-only : pas de fusion, juste une concaténation ordonnée par (sessionId, seq).
  • Un conflit n'est possible que si la même partie avance sur deux appareils en même temps. Règle simple et suffisante : une session est verrouillée par appareil (bail renouvelé) ; sinon on fourche la partie et on le dit.

⚠️ Piège connu du lab : les écritures local-first ne se testent pas via browserless sur host.docker.internal (pas de crypto.subtle hors contexte sécurisé). Tester en Playwright sur localhost.


5. Versionnement du contenu et des sauvegardes

C'est la dette technique dont on ne se remet pas si on l'ignore.

Une sauvegarde event-sourcée n'est qu'une recette : elle ne vaut que si on a encore les ingrédients. Donc chaque session stocke :

type SessionHeader = {
sessionId: string
packId: string
contentHash: string // hash du bundle de contenu utilisé
engineVersion: string // semver du package engine
seed: string // graine maîtresse de la partie
startedAt: string
}

Politique retenue :

  • Le rejeu à l'identique n'est garanti que pour (contentHash, engineVersion) identiques.
  • Sinon on ne rejoue pas le journal : on repart du dernier snapshot, et on affiche « cette partie a été commencée avec une version antérieure du jeu ».
  • Le snapshot est donc obligatoire, pas une optimisation. Un snapshot tous les N événements et à chaque fin de journée.
  • Les migrations de snapshot sont numérotées et testées sur des fixtures réelles, comme les migrations PocketBase.

6. IA générative — où elle a sa place, et où elle n'en a pas

Le lab a un LM local (service brain / LM Studio sur EVOX2) et des générateurs d'images. Utilisation raisonnable :

UsageVerdict
Proposer un dépouillement à partir du texte d'une scène, dans l'éditeur✅ excellent — l'auteur corrige, gain de temps énorme
Générer les portraits des personnes, les photos de plateau, les repérages✅ oui, en asset pipeline, généré une fois et commité
Reformuler un SMS de PNJ en trois variantes, à l'écriture✅ oui, en aide à l'auteur
Générer des aléas au runtime❌ non — non déterministe, donc incompatible avec le rejeu, invérifiable, hors ligne impossible, et la qualité d'écriture est le produit
Jouer le réalisateur en dialogue libre⚠️ tentant, très cher en cohérence ; à garder pour une v2, et hors du chemin critique de l'état

Règle : l'IA est un outil d'atelier, pas un composant du moteur. Tout ce qu'elle produit est figé dans le contenu versionné avant d'atteindre une partie.

⚠️ Ne pas confondre procédural et IA. Le cadrage demande de la génération procédurale et de l'écriture à la main et des packs générés — les trois sont compatibles, à condition de séparer :

  • procédural seedé (composer un vivier, tirer la météo, faire varier les tarifs, choisir quelles dettes sont armées) → déterministe, donc à sa place dans le moteur, au runtime ;
  • génération LLM (écrire un texte, décliner un incident, proposer un dépouillement, produire un pack entier) → non déterministe, donc hors du moteur : à l'écriture ou en étape de « cuisson » d'un pack, figée et hashée avant d'être jouable.

Développé dans 02-event-sourcing.md §5 et 05-backoffice-editor.md §5.


7. Déploiement

Cohérent avec le reste du lab (cf. note-vite, insta-pics) :

  • Front statique déployé par zipgo sur raspy2 — un nom en *.game.gabvdl.xyz (le jeu) semble le bon rayon.
  • PocketBase en conteneur, exposé sur un sous-domaine admin.<projet> avec le passage /api — c'est le motif déjà utilisé et il marche.
  • Le bundle de contenu est un asset statique du build : aucun appel réseau n'est nécessaire pour jouer, PocketBase ne sert qu'aux comptes, aux sauvegardes distantes et à l'éditeur.
  • L'éditeur, lui, exige PocketBase et reste derrière l'auth.

8. Ce qui reste à trancher

Repris dans 08-open-questions.md :

  1. Monorepo npm workspaces + turbo dès le début, ou un seul paquet avec des dossiers et une règle de lint ? (le second est plus rapide à démarrer, le premier empêche vraiment les imports interdits)
  2. L'éditeur : route de l'app de jeu, ou application séparée ? (partagé au début, séparé quand il grossit)
  3. Le contenu est-il téléchargeable à chaud (nouveaux packs sans redéploiement) ou figé dans le build ?
  4. Les sauvegardes distantes dès la v1, ou local-only pour le prototype ?