# Architecture technique > **Ce document arrête les décisions d'implémentation du moteur.** Il est au code ce que les > autres fichiers de `rules/` sont au design : la source de vérité. Si le code s'en écarte, > c'est ce document qu'il faut corriger dans la même session — voir le contrat en tête de > `../CLAUDE.md`. > > Convention de statut : **✅ arrêté** · **🟡 proposé / à confirmer** · **❓ ouvert**. --- ## 1. Le principe directeur : une frontière, une seule > **`sim/` ne connaît ni Three.js, ni le DOM, ni le réseau, ni l'heure qu'il est.** > Le rendu lit l'état de la simulation. Il ne le modifie jamais. C'est la décision la plus structurante du projet, et c'est celle qu'il ne faudra jamais assouplir « juste pour cette fois ». Elle donne, gratuitement : - **le serveur autoritaire** — c'est le même code de simulation qui tourne côté serveur ; - **le rejeu déterministe comme stratégie de test** — on rejoue un journal d'ordres sans le moindre pixel, en quelques millisecondes ; - **le travail en parallèle** — le rendu, le réseau et le gameplay peuvent avancer en même temps, chacun contre une frontière stable ; - **le changement de direction artistique** sans toucher au jeu. Toute fonctionnalité qui exige de franchir cette frontière est mal conçue : c'est le signal qu'il manque un état dans la simulation, ou un événement en sortie. ### ✅ Le sens de circulation ``` entrées joueur ORDRES ÉTAT + ÉVÉNEMENTS (souris, clavier) ──────────────▶ sim/ ──────────────────▶ rendu, HUD, son client réseau (pure) lecture seule ``` La simulation produit deux choses : un **état** (interrogeable à tout moment) et un **journal d'événements du tick** (« unité 42 a tiré », « le bâtiment 7 est terminé », « la cellule 1834 est devenue de l'obsidienne »). Le rendu s'abonne aux événements pour déclencher animations, sons et particules ; il lit l'état pour dessiner. Il n'écrit rien. --- ## 2. La pile technique | Domaine | Choix | Note | |---|---|---| | Langage | **TypeScript**, partout | `strict: true`, y compris `noUncheckedIndexedAccess` | | Runtime & outillage | **Bun** | TypeScript natif sans étape de transpilation, bundler, test runner, serveur HTTP et WebSocket, client SQLite — le tout intégré | | Bundler client | **`bun build`** | Un seul outil, pas de Vite ni d'esbuild à ajouter | | Tests | **`bun test`** | Compatible Jest, démarrage quasi instantané — indispensable quand la suite est faite de rejeux | | Serveur HTTP + WebSocket | **`Bun.serve()`** | WebSocket de première classe (uWebSockets en dessous), pas de dépendance `ws` | | Base de données | **`bun:sqlite`** | Intégré, synchrone, très rapide. Un fichier, zéro administration | | Mots de passe | **`Bun.password`** (argon2id) | Intégré, pas de dépendance de crypto | | Rendu 3D | **Three.js r180, vendorisé** (`vendor/three-build/`, par importmap) | Voir l'encadré ci-dessous : le passage à npm est reporté, pas abandonné | | HUD & carte du monde | **DOM + CSS**, sans framework | Le HUD se met à jour à 60 Hz depuis l'état : un diff de VDOM par frame serait du gaspillage | | Dépendances de production | **`three` — et c'est tout** | Tout le reste est soit intégré à Bun, soit écrit ici | **Ce qu'on n'utilise volontairement pas** : aucun moteur de jeu (Babylon, PlayCanvas), aucune bibliothèque ECS, aucune bibliothèque physique, aucun framework front, aucun framework HTTP. La convention « zéro asset externe » de `../CLAUDE.md` reste valable : toute la géométrie en code, toutes les textures dessinées en ``. ### ✅ Three.js reste vendorisé pour l'instant Le plan initial était de prendre `three` en npm, résolu par le bundler. Il est **reporté** pour une raison bête mais réelle : `/srv/obsidian/node_modules` est un **symlink vers un autre projet** (`/root/kestrel-e2e/node_modules`), dont dépend `shot.mjs` pour Puppeteer. Un `bun add three` irait écrire chez le voisin. En attendant, **une seule façon de charger three dans tout le dépôt** : `vendor/three-build/` par importmap — c'est déjà ce que font les 13 maquettes, l'atelier et `client/pieces/`. Cette uniformité vaut mieux que la résolution npm, qui n'apporterait rien de plus aujourd'hui. Le jour où le client aura besoin d'un vrai bundle : détacher le symlink, réinstaller Puppeteer pour l'outillage Node, puis `bun add three`. C'est noté comme dette dans `SUIVI.md`. ### ✅ Node reste installé, mais n'est plus la cible `/opt/node22` et le service `obsidian.service` servent encore la galerie de maquettes (`serve.js`, `shot.mjs` + Puppeteer). Rien à y toucher. Le moteur, lui, est écrit pour Bun. --- ## 3. Le modèle réseau ### ✅ Serveur autoritaire, à simulation déterministe, qui relaie les ordres Ni du lockstep pur (aucun arbitre, donc triche facile et désynchronisation fatale), ni des instantanés d'état à chaque frame (trop de bande passante pour des centaines d'unités). 1. Le client émet des **ordres**, jamais de l'état. « Ces 12 ouvriers vont récolter là », pas « cet ouvrier est maintenant à cette position ». 2. Le serveur reçoit l'ordre au tick courant `T`, le **planifie pour le tick `T + 2`** (200 ms) et le **diffuse à tout le monde**. 3. À `T + 2`, le serveur **et** tous les clients exécutent exactement le même jeu d'ordres sur exactement le même état → ils obtiennent exactement le même résultat. 4. Le serveur reste **l'arbitre** : c'est son état qui est écrit en base à la fin du match. Bande passante : quelques dizaines d'octets par ordre, une poignée d'ordres par seconde et par joueur. Le nombre d'unités n'y change **rien** — c'est tout l'intérêt. ### ✅ Le commit : l'autorisation d'avancer Un client n'exécute **jamais** un tick que le serveur n'a pas déclaré définitif. À chaque tick, le serveur diffuse un **commit** — « les ticks jusqu'à `N` sont clos, voici les ordres à venir » — et le client rejoue tout ce qui est autorisé, pas un tick de plus. Un commit part **à chaque tick, même vide** (`COMMIT_IDLE_TICKS = 1`, `shared/protocol.ts`). Il pèse 40 octets ; à 10 Hz et 4 joueurs, un match coûte 1,6 Ko/s de battement de cœur. Regrouper les commits économiserait des messages et coûterait de la latence visible : le réglage existe, il ne doit pas bouger sans raison sérieuse. De là découle **l'invariant qui rend la convergence gratuite** : un ordre reçu au tick `T` est planifié pour `T + 2` et diffusé dans le commit de `T`. Comme `T < T + 2`, un client reçoit toujours un ordre **avant** l'autorisation de jouer le tick concerné — aucun ordre ne peut lui arriver en retard. ### ✅ L'enveloppe d'un ordre, et son tri total Tout ordre porte `(tick, joueur, séquence)` en plus de sa charge utile. Deux machines reçoivent les mêmes ordres mais **pas dans le même ordre** — c'est le réseau : avant exécution, les ordres d'un tick sont donc triés sur ce triplet, qui doit être **unique**. **Qui décide de quoi** : le client numérote (`séquence`), le serveur date (`tick`). Un client n'a jamais son mot à dire sur le moment où son ordre s'exécute — sinon il lui suffirait de dater dans le passé pour agir après coup. Deux obligations qui en découlent, pour le client comme pour le serveur : - le **numéro de séquence est strictement croissant par joueur** sur toute la partie ; un numéro déjà vu est traité comme un doublon réseau et **écarté** (le rejouer ferait diverger cette machine des autres) ; - un ordre arrivé **après** son tick est écarté et compté, jamais exécuté en retard. Le tri est écrit à la main dans `sim/orders.ts` : `Array.prototype.sort` est interdit dans `sim/` — à clé égale, il départage arbitrairement. ### ✅ Détection de désynchronisation - Tous les **50 ticks (5 s)**, chaque client envoie au serveur un **hachage de son état** (positions, points de vie, ressources, terrain). - Le serveur compare avec le sien. Divergence → il envoie au client fautif un **instantané binaire complet**, qui repart de là. Le joueur voit au pire un saut. - `sim/hash.ts` calcule aussi un **hachage par domaine** (en-tête, terrain, entités, joueurs) : devant une divergence, savoir que c'est le terrain et pas les entités fait gagner des heures. Le hachage se fait **valeur par valeur, jamais octet par octet** — sinon il dépendrait du boutisme de la machine. - En développement, une divergence est une **erreur bloquante** avec le tick fautif : c'est comme ça qu'on trouve les bugs de déterminisme, et il faut les trouver tôt. Les **trois premières** d'un match sont racontées en entier ; au-delà le journal se tait et seul le compteur monte — quatorze lignes par divergence, sans plafond, c'est un terminal qu'un client hostile remplit avec des messages de 64 octets, et le mode développement est le mode par défaut. - **Un instantané ne part que s'il répare quelque chose.** C'est la seule réponse du serveur qui coûte mille fois son déclencheur (~300 Ko contre 18 octets), donc la seule qui ait besoin d'un frein. Après en avoir reçu un au tick `T`, un client est identique au serveur au bit près : un hachage annoncé pour un tick `≤ T` décrit un état qui n'existe plus, et ne vaut pas un second instantané. La dépense se borne ainsi d'elle-même à **un instantané par point de contrôle et par joueur**, sans jamais retarder une vraie divergence. ⚠️ Une **reconnexion** n'est jamais freinée : sans son instantané, le client repartirait d'un monde faux. - **Un client qu'on n'arrive pas à réparer est lâché.** Au bout de huit divergences réparées et revenues, le serveur cesse de répondre à ce siège et ferme la connexion (`desync_limit`) : le neuvième instantané ne servira pas plus que les huit premiers. Le délai de grâce de `combat.md` prend alors le relais. ### ✅ Retour immédiat sans tricher Les 200 ms de latence d'ordre ne doivent pas se sentir. Le client répond **immédiatement** — curseur, halo de sélection, son de confirmation, marqueur de destination — mais **uniquement dans la couche rendu**. La simulation, elle, attend son tick. Rien de ce qui est affiché en avance n'a d'effet sur le jeu. ### ✅ Déconnexion et reconnexion Conforme à `combat.md` : le joueur reste dans la partie, immobile. À la reconnexion (2 min max), le serveur envoie un **instantané complet** plus les ordres depuis ce tick. Passé le délai, le serveur injecte lui-même un ordre d'abandon. ⚠️ **L'état de connexion n'est pas de l'état de simulation.** `PlayerStatus.Disconnected` existe dans `sim/world.ts` mais **le serveur ne l'écrit pas** : le faire hors d'un ordre le ferait diverger de ses clients, puisque ce champ est haché. Les allées et venues circulent donc dans un message hors simulation (`conn`), et seul l'**abandon** — qui, lui, est un vrai ordre — touche à l'état. ### ✅ Format des messages - **Ordres et messages de lobby : JSON.** Le volume est négligeable et la lisibilité en débogage vaut plus que les octets économisés. - **Instantanés d'état et terrain : binaire** (`DataView`, schéma versionné explicitement). C'est là qu'il y a du volume, et seulement là. Une trame binaire porte un en-tête de 12 octets — type, tick, charge libre — qui laisse la charge utile alignée sur 4. Le protocole complet (tous les messages, leur validation, le cadrage binaire) est écrit et commenté dans **`shared/protocol.ts`** ; `server/reference-client.ts` en est la moitié cliente, sans un pixel, et sert de modèle au vrai client. --- ## 4. Le déterminisme — les règles non négociables Deux exécutions du même jeu d'ordres, sur deux machines, deux navigateurs, deux systèmes, doivent produire le **même état bit à bit**. C'est ce qui fait tenir tout ce qui précède. ### ✅ Virgule fixe : aucun flottant dans l'état de simulation - **Q16.16 sur `Int32`** : 1 unité monde = 65 536. Positions, vitesses, points de vie, ressources, tout. - Multiplication et division passent par des utilitaires dédiés (`fxMul`, `fxDiv`) — jamais `*` ni `/` directement sur des grandeurs en virgule fixe. - Racines carrées et angles : tables précalculées en entier. Pas de `Math.sqrt` dans la sim. - Le flottant reste **autorisé côté rendu** — interpolation, caméra, particules — puisque rien de tout cela ne revient dans la simulation. ### ✅ Interdits dans `sim/` | Interdit | Pourquoi | À la place | |---|---|---| | `Math.random()` | non reproductible | **PCG32** semé par le match, un flux par domaine | | `Date.now()`, `performance.now()` | dépend de la machine | le **numéro de tick** | | `setTimeout`, `async`, promesses | ordre d'exécution non garanti | tout est synchrone dans le tick | | Flottants dans l'état | arrondi variable selon la plateforme | virgule fixe | | Itérer un `Map`, un `Set`, un `Object.keys` pour la logique | ordre d'insertion → dépend de l'historique | tableaux indexés, parcourus par index croissant | | `Array.prototype.sort` sans clé totale | égalités départagées arbitrairement | trier sur une clé unique (ex. l'identifiant d'entité) | ### ✅ Comment on le vérifie, plutôt que de l'espérer 1. **Un test de pureté** (`sim/__tests__/purity.test.ts`) qui analyse les sources de `sim/` et échoue sur le moindre identifiant interdit, et sur tout import hors de `sim/`. Il retire commentaires et chaînes avant analyse — un commentaire a le droit de citer ce qu'il interdit — et il est lui-même couvert par des tests, sans quoi un analyseur cassé rendrait `sim/` « pur » par accident. 2. **Des tests de rejeu** : un journal d'ordres + le hachage d'état attendu au tick `N`. C'est l'essentiel de la suite de tests du projet. Trois variantes valent d'être écrites pour chaque système : le même journal joué deux fois, le même journal reçu **dans le désordre**, et une partie **coupée en deux par un instantané** — cette dernière est la seule qui prouve que tout l'état est bien sérialisé. 3. **Un rejeu croisé** en intégration continue : le même journal rejoué sous Bun et dans un navigateur headless doivent donner le même hachage. --- ## 5. Découpage du dépôt ``` /srv/obsidian ├── sim/ LA SIMULATION — pure, déterministe, zéro dépendance │ ├── fixed.ts virgule fixe Q16.16, racine et tables trigonométriques entières │ ├── rand.ts PCG32, un flux par domaine │ ├── world.ts LA DISPOSITION D'ÉTAT : tableaux typés, entités, instantané binaire │ ├── hash.ts hachage de l'état complet, et par domaine │ ├── orders.ts types d'ordres, tri déterministe total, file d'attente │ ├── tick.ts LE point d'entrée : (état, ordres) → état, événements │ ├── tuning.ts le module unique de chiffrage, reflet de `valeurs.md` │ ├── mapgen.ts génération d'une carte à partir d'une graine │ ├── terrain.ts creusage, altitudes, surfaces │ ├── fluid.ts écoulement gravitaire, obsidienne │ ├── path.ts champs de flux, coût de pente │ ├── move.ts déplacement, séparation locale │ ├── economy.ts récolte, dépôt, ressources │ ├── buildings.ts chantiers, production d'unités │ ├── combat.ts cible, portée, dégâts, bonus de hauteur │ └── victory.ts élimination, fin de partie ├── shared/ protocole réseau, encodage binaire des messages ├── client/ Three.js, entrées, HUD, carte du monde │ ├── render/ portage chunké de « Bois nu » (voir terrain.md) │ ├── pieces/ le catalogue : une géométrie + un squelette par pièce (voir pieces.md §2) │ └── game/ LA VUE DU JEU : scène, caméra, entrées, HUD, réseau │ (entrées et HUD ne sont pas séparés : ils se tiennent │ par la boucle d'image, les couper ne produirait que │ des imports croisés) ├── server/ Bun.serve, WebSocket, lobbies, SQLite, persistance ├── tools/ générateur de cartes, rejeu en ligne de commande, captures ├── mockups/ les 13 maquettes de style — INCHANGÉES, elles ne dépendent de rien d'ici └── rules/ ce dossier ``` **La règle d'import qui garde tout en place** : `sim/` n'importe rien — ni bibliothèque, ni autre dossier du projet ; à l'intérieur de `sim/`, les fichiers s'importent entre eux par chemin relatif. `shared/` peut importer `sim/`. `client/` et `server/` peuvent tout importer. Jamais l'inverse. C'est vérifié par le test de pureté. ### ✅ Le module de chiffrage vit dans `sim/`, pas dans `shared/` Conséquence directe de la règle ci-dessus : puisque `sim/` n'importe rien d'extérieur, le **module unique de constantes** exigé par `valeurs.md` ne peut pas être dans `shared/`. Il est donc dans **`sim/tuning.ts`**, et `shared/` ne porte que le protocole réseau. Le jour où les valeurs viendront d'une table en base, elles seront **injectées à la création du monde**, pas importées — c'est la seule façon de les changer sans redéployer tout en gardant `sim/` étanche. --- ## 6. La simulation ### ✅ Cadence - **Tick de simulation : 10 Hz** (100 ms). Un RTS n'a pas besoin de plus, et c'est ce qui rend le coût réseau et le coût CPU de la simulation négligeables. - **Rendu à la fréquence de l'écran**, qui **interpole** entre le tick précédent et le tick courant. C'est l'interpolation qui donne la fluidité, pas la cadence de simulation. ### ✅ L'ordre d'appel des systèmes est figé Un tick appelle six systèmes, **toujours dans cet ordre** (`sim/tick.ts`) : | | Système | Pourquoi là | |---|---|---| | 1 | **ordres** | l'intention des joueurs entre dans le monde | | 2 | **fluides et terraformation** | le terrain bouge avant que quiconque marche dessus | | 3 | **pathfinding et déplacement** | les unités se déplacent sur le terrain tel qu'il vient de devenir | | 4 | **économie, récolte, construction** | à la position qu'on vient d'atteindre | | 5 | **combat** | on frappe qui est à portée, à la position qu'on vient d'atteindre | | 6 | **conditions de victoire** | on constate les éliminations une fois tous les dégâts appliqués | Intervertir deux systèmes **change le jeu** et invalide tous les rejeux enregistrés : cet ordre ne se modifie pas sans décision explicite. Le journal d'événements est vidé au début du tick, le numéro de tick est incrémenté à la fin — pendant l'exécution, `world.tick` est bien le tick en cours. ### ✅ État en tableaux typés (SoA) Pas d'objets par unité, pas de bibliothèque ECS : des tableaux parallèles indexés par identifiant d'entité (`posX: Int32Array`, `posY: Int32Array`, `hp: Int32Array`, `kind: Uint8Array`…). Trois raisons, dans cet ordre : le **déterminisme** (l'ordre de parcours est l'ordre des index, point), la **sérialisation** (un instantané est une copie de mémoire), la performance. **La disposition complète est écrite dans `sim/world.ts`** — c'est le contrat que tous les systèmes partagent, et il est commenté comme tel. Les trois magasins (terrain, entités, joueurs) sont déclarés dans des **tables de champs** dont découlent automatiquement l'allocation, le hachage et l'instantané binaire : ajouter un champ, c'est ajouter une ligne. Une **empreinte de disposition** est calculée à partir de ces tables et écrite en tête de chaque instantané, si bien qu'un instantané produit par une autre version du moteur est refusé au lieu d'être relu de travers. **Index et poignée.** L'*index* est la ligne dans les tableaux. La *poignée* est `(génération << 16) | index` : c'est elle qu'on stocke dès qu'on **désigne une autre entité** (cible de tir, chantier, occupant d'une cellule). Quand un emplacement est recyclé, sa génération avance et toutes les poignées qui le désignaient deviennent détectables comme périmées. Les emplacements libérés sont repris en **LIFO**, donc dans un ordre reproductible. **Ce qui est de l'état, et ce qui n'en est pas.** Est de l'état — donc sérialisé et haché — le numéro de tick, les flux d'aléa, les trois magasins, la liste libre, l'issue du match. N'en sont pas : le journal d'événements du tick et les structures d'accélération (`world.derived`). Tout ce qui vit hors de l'état doit être **reconstructible depuis l'état** : après le chargement d'un instantané, ces structures sont refaites, et tout le terrain est déclaré sale. ### ✅ Terrain Conforme à `terraformation.md` — une seule couche. | Tableau | Type | Contenu | |---|---|---| | `altitude` | `Int16Array` | l'altitude courante, en niveaux entiers | | `altitudeOrigine` | `Int16Array` | l'altitude avant creusage — sert à combler quand l'obsidienne se forme | | `surface` | `Uint8Array` | herbe, terre, roche, forêt, cendre, obsidienne… | | `flags` | `Uint8Array` | creusée (= lit) · bâtie · infranchissable · source · front de fluide actif | | `fluide` | `Uint8Array` | rien · eau · lave | | `resource` | `Int16Array` | quantité récoltable restante sur la cellule (forêt, carrière) | | `occupant` | `Int32Array` | poignée du bâtiment qui occupe la cellule, ou « aucun » | À 96×96, l'ensemble tient dans **~110 Ko**, et l'instantané complet d'un match dans ~300 Ko. C'est négligeable à transmettre — ce qui simplifie beaucoup la reconnexion. **Chunks de 16×16 cellules → 36 chunks**, alignés sur le découpage prévu par `terrain.md` §5 pour le rendu. Une modification marque son chunk **et le voisinage nécessaire** comme sales ; seuls les chunks sales sont retraités, côté simulation comme côté rendu. Le suivi se fait par **compteur de version par chunk**, pas par simple drapeau : il y a plusieurs consommateurs (rendu, champs de flux, fluides), et si le premier à passer effaçait le drapeau, les autres rateraient la modification. Chacun garde donc sa copie des versions vues et compare — `markCellDirty(world, x, y)` après toute écriture qui change l'apparence ou la franchissabilité d'une cellule. ### ✅ Fluides L'automate ne parcourt **jamais** toute la grille : il maintient une **liste de cellules actives** (le front d'écoulement). Une cellule sort de la liste quand elle est stable. Un creusage, un versement de seau ou une formation d'obsidienne réveillent le voisinage. Parcours **par index croissant**, propagation en **4-voisinage**, résolution en **double tampon** (on lit l'état du tick précédent, on écrit dans le suivant) — sans quoi le résultat dépendrait de l'ordre de parcours, ce qui est exactement ce qu'on s'interdit. L'automate **tire, il ne pousse pas** : chaque cellule calcule *sa* valeur suivante en regardant ses quatre voisines, aucune n'écrit chez une autre. C'est ce qui rend le résultat insensible à l'ordre de visite, et ce qui fait que la rencontre eau/lave se décide au même endroit que le reste, sans cas particulier d'arbitrage. ✅ **Vitesse de propagation : une cellule tous les 2 ticks** (`valeurs.md`), en ticks entiers. ✅ **Le front actif est un bit d'état, la liste n'est qu'un raccourci.** `FLAG_FLUID_ACTIVE` est haché et sérialisé ; la liste qui l'accompagne vit hors de l'état et se **reconstruit d'elle-même dès que le terrain bouge hors du module de fluides** — le témoin est `world.derived.terrainVersion`. C'est ce qui garantit qu'une machine qui vient de recharger un instantané et une machine qui a rejoué en continu repartent du **même** front, et c'est pour ça que `markCellDirty` après toute écriture terrain n'est pas une politesse mais une obligation. ### ✅ La charge utile des événements `EventKind` porte quatre entiers libres ; le sens de `a b c d` est fixé **par émetteur**, et c'est ici qu'il est écrit. Le rendu (Agent 5) lit cette table, il n'a rien à deviner. | Événement | a | b | c | d | |---|---|---|---|---| | `CellDug` | cellule | altitude atteinte | poignée du creuseur | niveaux creusés | | `FluidChanged` | cellule | `Fluid` nouveau | `Fluid` précédent | 0 | | `ObsidianFormed` | cellule | altitude comblée | 0 | 0 | | `BucketPoured` | cellule | `Fluid` versé | joueur | cellules mouillées | | `OrderRejected` | joueur | `OrderKind` | motif | paramètre | | `EntitySpawned` | poignée | joueur | `EntityKind` | poignée du producteur | | `BuildingCompleted` | poignée | joueur | `EntityKind` | cellule nord-ouest | | `ResourceGained` | joueur | `ResourceKind` | quantité Q16.16 | poignée de l'**ouvrier** qui dépose, ou du **bâtiment** qui produit seul | | `EntityDamaged` | poignée de la cible | poignée de l'attaquant | dégâts Q16.16 | 1 si bonus de hauteur | | `EntityDied` | poignée | joueur | `EntityKind` | poignée du tueur (−1 si aucun) | | `PlayerEliminated` | joueur | 0 | 0 | 0 | | `MatchEnded` | `MatchStatus` | vainqueur, ou −1 | tick | 0 | | `BuildingCancelled` | poignée | joueur | `EntityKind` | cellule d'origine | Le **motif** d'un `OrderRejected` se lit selon le `OrderKind` porté par `b` : `EconomyReject` (`sim/buildings.ts`) pour `Build` · `Train` · `Gather`, `CombatReject` (`sim/combat.ts`) pour `Attack`. Un rejet posé par `sim/tick.ts` lui-même (joueur éliminé, séquence rejouée) porte la **séquence** en `c` et rien en `d`. #### ✅ Un ordre refusé ne modifie aucun état d'entité L'invariant « un nouvel ordre efface l'ordre précédent » ne vaut que pour un ordre **accepté**. Un refus laisse l'unité exactement comme elle était : même état, même cible, même destination, même charge. `dispatchPerEntity` retient les huit champs de `clearEntityOrder` avant d'appeler le propriétaire, et les lui rend s'il répond `false`. Ce n'était pas le cas jusqu'au 2026-08-13, et ça punissait le joueur d'avoir essayé : un clic droit sur son propre hôtel de ville, ou sur un bâtiment qu'on n'a pas les moyens de payer, arrêtait **définitivement** les cinq ouvriers sélectionnés — l'un d'eux gardant sa charge de bois sur le dos jusqu'à la fin de la partie. Un refus doit rester **sans effet** : c'est la seule promesse qui rende un clic sans danger, et c'est ce qui permet d'apprendre le jeu en essayant. Corollaire pour les propriétaires : un gestionnaire par entité rend `true` s'il a pris l'ordre, `false` sinon, et **ne touche à rien avant d'avoir décidé**. `handleSetRally` rend toujours `false` — poser un point de ralliement est un réglage, pas un ordre de travail, et il n'a aucune raison d'interrompre une production en cours. La table est complète : les treize `EventKind` y sont. ### ✅ Pathfinding : champs de flux - **Un champ de flux par destination**, partagé par toutes les unités qui y vont. Cent unités vers le même point coûtent le prix d'un seul champ. - Coût de franchissement dérivé de la pente entre deux cellules voisines (voir `terraformation.md`) ; au-delà de la pente maximale, la cellule est **infranchissable**. - Quand un chunk devient sale (creusage, inondation, bâtiment posé, obsidienne), les champs de flux qui le traversent sont **recalculés sur ce chunk seulement**. - Évitement local : **séparation simple + réservation de cellule d'arrivée**. Déterministe et bon marché. Pas d'ORCA/RVO — trop délicat en virgule fixe pour le gain visuel. **Écrit et livré** — `sim/path.ts` (les champs) et `sim/move.ts` (le déplacement). Ce qui a été tranché à l'écriture, et qui n'était pas déductible de ce qui précède : #### ✅ La direction n'est jamais mémorisée, elle se déduit du champ Un champ ne stocke que des **distances**. La cellule suivante se recalcule à chaque lecture, en parcourant les 8 voisines dans un ordre fixe. Raison : les distances d'un plus court chemin sont **uniques**, le parent choisi ne l'est pas en cas d'égalité. Une direction mémorisée dépendrait de l'ordre de propagation, donc de l'historique du cache, et deux machines finiraient par emprunter deux couloirs différents. Ici la direction est une fonction pure de `(dist, terrain)`. #### ✅ Le recalcul incrémental donne *exactement* le champ d'un recalcul complet « Recalculer sur le chunk sale » au sens littéral serait faux : une cellule éloignée dont le plus court chemin traversait ce chunk devient fausse elle aussi. La zone à refaire est fermée par la règle *une cellule reste valide si et seulement si elle garde un voisin valide `p` tel que `dist[p] + coût(c → p) === dist[c]`* — elle garde alors un chemin praticable et inchangé jusqu'au but. Dijkstra est ensuite relancé depuis la frontière restée valide. Comme les distances sont uniques, le champ réparé est **bit à bit** celui d'un recalcul complet ; sans quoi un client passé par un instantané divergerait du serveur. Vérifié par comparaison directe sur des centaines de modifications de terrain, et de bout en bout par les rejeux coupés par instantané. #### ✅ Diagonales autorisées, mais jamais en coupant un coin Un pas diagonal coûte √2 et n'est permis que si les **deux** cellules orthogonales du coin sont elles-mêmes franchissables. Sans cette règle, une douve d'une cellule de large se traverserait en biais et le geste défensif du jeu ne vaudrait plus rien. #### ✅ Destination inatteignable : l'unité **ne part pas** | Situation | Comportement | |---|---| | La destination cliquée est infranchissable (falaise, lave, bâtiment) | on vise la cellule franchissable la plus proche, dans un rayon borné | | Aucune cellule franchissable dans ce rayon | l'ordre n'a pas d'effet, l'unité reste inactive | | La destination est franchissable mais **inatteignable depuis la position de l'unité** | l'unité **ne bouge pas** et redevient inactive | | Le guidage d'approche ne trouve plus de pas améliorant | elle s'arrête **au plus près** | Le choix « elle ne part pas » plutôt que « elle s'approche au plus près » est délibéré : l'unité reste où le joueur l'a laissée, au lieu de venir s'agglutiner contre le bord de la douve qu'on vient de creuser — et de s'y faire cueillir. #### ✅ Le déplacement mène **aussi** les états de métier, et l'arrivée ne les efface pas Une unité ne marche pas seulement quand elle est en `EntityState.Moving`. Un ouvrier **reste en `Gathering` / `Returning` / `Building` / `Digging` / `Pouring` pendant tout son trajet** : les systèmes propriétaires du métier (économie, terraformation) ne changent que `destX`/`destY`. Le déplacement mène donc **toute entité non statique dans un état de métier dont la destination n'est pas atteinte**, et à l'arrivée il lui **laisse son état** — c'est cet état que l'économie relit deux emplacements plus loin, dans le même tick. La destination du moment se lit alors dans **`destX`/`destY`**, pas dans `targetCell` : un ouvrier qui rentre déposer garde `targetCell` sur le gisement où il **repartira**. Une unité en `Moving` ou au combat, elle, suit bien `targetCell`, et son arrivée la repasse à `Idle` — c'est ce qui lui rend la main pour réacquérir une cible. > Le jour où cette règle n'était pas tenue, **aucun ouvrier ne se déplaçait dans le vrai jeu**, et > aucun test ne le voyait : les tests d'économie passaient par un ersatz de déplacement. Voir > `sim/__tests__/gather-trip.test.ts`, qui joue le voyage complet sur une carte générée. #### ✅ La séparation ne peut jamais annuler le pas La poussée d'évitement est bornée à une **fraction** du pas (`SEPARATION_PUSH`, `valeurs.md`), jamais au pas entier. Bornée au pas, elle pouvait l'annuler exactement : une unité qui vient buter sur une camarade **à l'arrêt** — qui, elle, ne recule pas — trouvait un point d'équilibre et s'y immobilisait définitivement. Avec ce plafond, l'avancée l'emporte toujours et l'unité finit par contourner ou passer. #### ✅ `world.ent.flowField` porte un **rang de tick**, pas un numéro de cache Ce champ est de l'état : il est haché et sérialisé. Un numéro d'emplacement de cache dépendrait de l'historique du cache et produirait une **fausse désynchronisation** après une reconnexion par instantané. On y écrit donc le rang de la destination dans une table reconstruite à chaque tick en parcourant les entités par index croissant — une fonction pure de l'état. #### 🟡 Limite connue : le nombre de destinations distinctes simultanées `MAX_FLOW_FIELDS` (`valeurs.md`) borne le nombre de champs vivants. Au-delà, les unités surnuméraires se guident **à vue** : elles respectent toujours les falaises, mais ne contournent plus un grand obstacle. À rejuger si des parties à 4 joueurs très morcelées le montrent. ✅ **La pente maximale est tranchée** (`MAX_CLIMB_LEVELS` = 1) et le test de franchissement est écrit une fois pour toutes — mais il est **en deux étages**, et les confondre a déjà produit deux règles divergentes qu'il a fallu réconcilier à la fusion (`SUIVI.md`, « Arbitrages rendus à la fusion ») : | | Où | Ce que c'est | |---|---|---| | `climbAllowed(monde, de, vers)` | `sim/terrain.ts` | **la règle de pente, et elle seule** — l'unique endroit du dépôt où `MAX_CLIMB_LEVELS` s'applique | | `terrainStepAllowed(monde, de, vers)` | `sim/terrain.ts` | pente + obstacles gravés dans le sol. **Ce n'est pas la règle de déplacement** : elle ignore les bâtiments, la lave et les diagonales | | **`stepAllowed(monde, ax, ay, bx, by)`** | **`sim/path.ts`** | **la règle qui fait autorité pour une unité.** Elle appelle `climbAllowed` et ajoute bâtiments, lave, coins de diagonale et bords de carte | Un système qui veut savoir si une unité passe appelle **celle de `sim/path.ts`**, jamais une autre, et ne refait jamais le calcul lui-même : sans quoi le rendu, la simulation et le joueur finiraient par ne plus être d'accord sur ce qui se franchit. `FLAG_IMPASSABLE`, lui, est un **raccourci local** entretenu par le terrain : « cette cellule ne s'entre pas » — obsidienne, lave, ou cellule dont plus aucune voisine n'est à portée d'enjambée (fond de douve, sommet de falaise). Il ne dit rien d'une *arête*. ### 🟡 Où tourne la simulation côté client Dans le **thread principal** pour commencer : 10 Hz sur ~9 200 cellules et quelques centaines d'unités, le budget est large. Si le profilage montre des à-coups, on la déplace dans un **Web Worker** — ce qui exigera un `SharedArrayBuffer`, donc les en-têtes COOP/COEP côté serveur. À prévoir dans un coin de la tête, pas à faire tout de suite. --- ## 7. Le client - ✅ **Rendu : `client/render/`, livré.** Portage de `mockups/style-boisnu.js` en chunks de 16×16 cellules — **le découpage de `sim/world.ts`**, pas un autre —, mise à jour par `addUpdateRange` sans jamais reconstruire une géométrie. Coût mesuré : **0,4 ms par pelletée** (médiane, navigateur), budget tenu. Détail et mesures dans `terrain.md` §5 et `client/render/README.md`. - ✅ **La lisibilité d'un trou est tranchée** : le lissage est **sélectif** — généreux sur le relief naturel, court sur les falaises, **franc sur ce que le joueur creuse** —, et le creusage sort en plus en attribut par sommet (terre fraîche non peinte, ombre portée). Un trou d'une cellule se voit à 70 unités de recul, vérifié sur capture (`terrain.md` §5). - Le rendu **lit** `world.terrain` et les compteurs de version de `world.derived` ; il tient sa propre copie des versions vues et ne remet jamais rien à zéro — il n'est pas le seul consommateur. - ✅ **La vue du jeu : `client/game/`, livrée.** Elle assemble `client/render/` (le terrain), `client/pieces/` (les 132 poses) et `sim/` — **compilé** pour le navigateur par `client/game/build.mjs`, jamais recopié. Elle tourne hors ligne (simulation dans l'onglet) comme en réseau (le `ReferenceClient` sur une vraie WebSocket). Mode d'emploi : `client/game/README.md`. - ✅ **La caméra de jeu est arrêtée** : **70 unités de recul**, 46° d'inclinaison, champ de 38°, zoom borné à 22–130 (`valeurs.md` § « La caméra de jeu »). - **Interpolation** entre les deux derniers ticks pour le déplacement des unités ; les événements du tick déclenchent animations, sons et particules. La simulation tourne à 10 Hz et l'écran à celle du moniteur : **c'est l'interpolation qui donne la fluidité, jamais la cadence de simulation.** - **Le retour immédiat appartient au rendu, et il ne triche pas.** Halo de sélection, curseur, aperçu d'empreinte, marqueur de destination apparaissent au clic — 200 ms avant que l'ordre ne revienne — mais **aucun ne touche à l'état** et aucun n'anticipe un résultat. Un ordre refusé laisse simplement le marqueur s'éteindre. - **HUD en DOM**, mis à jour depuis l'état, sans framework — par **signature** : chaque zone garde l'empreinte de ce qu'elle affiche et se tait tant que rien ne bouge. - L'**atelier** (`mockups/atelier.html`, caméra libre ZQSD) sert d'établi pour modeler unités et bâtiments hors du jeu. C'est un outil de travail, pas une brique du moteur. --- ## 8. Le serveur Un seul processus Bun, sur le **port 10571** (10570 est pris par la galerie de maquettes) : - sert le client (fichiers statiques, avec garde-fou contre la remontée de chemin) ; - tient les **lobbies** et le matchmaking porté par la carte du monde (`matchmaking.md`) ; - fait tourner une **instance de simulation par match en cours** — 10 ticks par seconde et par match, donc des dizaines de matchs simultanés sur une seule machine ; - relaie les ordres, vérifie les hachages, envoie les instantanés ; - écrit le résultat en base à la fin du match. **Découpage** — `server/http.ts` (Bun.serve, routes, WebSocket) · `server/hub.ts` (carte du monde, lobbies, registre des matchs, **seul à parler à la base**) · `server/match.ts` (une instance de simulation, le relais d'ordres, l'arbitrage) · `server/db.ts` (SQLite) · `server/accounts.ts` (`Bun.password`, cookie signé) · `server/grid.ts` (la géographie du monde, pure) · `server/clock.ts` (10 Hz avec rattrapage). ❓ Le passage à plusieurs processus (un par lot de matchs) n'est pas nécessaire au départ, mais rien ne doit l'empêcher : **les matchs ne partagent aucun état en mémoire** — ni tableau, ni cache, ni compteur. ### ✅ Ce que le serveur refuse à un client Un serveur ouvert reçoit un jour un client bogué, et un jour un client hostile. Trois décisions, toutes appliquées, toutes chiffrées dans `server/config.ts` avec la mesure qui fixe le nombre : 1. **Un client a un débit de parole borné** — un seau à jetons par connexion, à 200 messages par seconde, soit quarante fois ce qu'émet un joueur de STR à 300 actions par minute. Les messages en trop sont **jetés**, pas mis en file : mettre en file, c'est accepter de payer plus tard ce qu'on refuse de payer maintenant. Le client l'apprend une fois par tick (`too_many_messages`). Un message dépassant 16 Ko est refusé **sans être analysé**. 2. **Aucune réponse ne doit peser mille fois sa demande.** Seul l'instantané est dans ce cas ; son frein est décrit en §3. 3. ⚠️ **Aucune exception ne s'échappe du gestionnaire de messages, jamais.** Un bug local doit coûter une action ignorée, pas une case du monde. Cette règle a un prix d'entrée exact : elle n'autorise **pas** à laisser un état à moitié modifié derrière soi. Le corollaire est écrit dans `server/hub.ts` — **on ne retire un lobby de sa case qu'une fois le match enregistré**, pour qu'aucune fenêtre ne puisse laisser une case sans lobby et sans match. Le filet ne dispense jamais de corriger la cause ; il fixe le prix maximal d'une cause non corrigée. Dans le même esprit : un match qui lève pendant son tick est **clos et sa case libérée**, jamais laissé à lever tick après tick — il bloquerait la boucle de tous les autres matchs du processus. ### ✅ Comptes et sessions Pseudo + mot de passe, hachés en **argon2id** par `Bun.password` (profil OWASP : 19 Mio, 2 passes). La session est un **cookie signé** `identifiant.expiration.HMAC-SHA256`, vérifié en temps constant, sans table de sessions à purger. Pas d'OAuth, pas de dépendance de crypto. Contrepartie assumée : on ne révoque pas une session isolée, seulement toutes à la fois en changeant le secret du serveur (généré au premier démarrage et gardé en base). Le jour où ça ne suffira plus, une table `sessions` remplacera l'identifiant dans le cookie. ### ✅ Fin de partie : qui décide `sim/victory.ts` décide, `world.status` fait foi — **c'est écrit et ça tourne**. Il bascule sur `WonByMonument` dès qu'un joueur non éliminé a un Monument terminé, et sur `WonByElimination` **dès qu'il ne reste qu'un joueur non éliminé**. ⚠️ **Un abandon est une élimination comme une autre** (`rules/combat.md`) : le partant est éliminé au tick de son ordre `Resign`, et ce qu'il laisse debout sur la carte cesse de compter. La simulation déclare donc la victoire militaire elle-même, sans attendre que la base du partant soit rasée. Le serveur garde son **garde-fou de processus** — et non une règle du jeu : il clôt une instance dont il ne reste au plus qu'un joueur non éliminé, parce qu'il n'a aucune raison de la faire tourner et qu'il doit libérer la case du monde. En pratique la simulation le devance sur toute vraie partie ; ce chemin ne sert plus qu'aux mondes où **aucun joueur ne prend part** (aucun n'a jamais tenu ni unité ni bâtiment) et à l'arrêt forcé du serveur. --- ## 9. Persistance — SQLite Un fichier, ouvert en WAL. Tables (schéma dans `server/db.ts`) : | Table | Contenu | |---|---| | `players` | compte, pseudo, hachage argon2id, expérience, argent (`progression.md`) | | `world_cells` | les 25 cases : faction propriétaire, monument actif | | `map_variants` | les 100 variantes : **graine de génération**, pas de heightmap stockée | | `cell_deltas` | ce qui persiste d'une partie à l'autre sur une case donnée — **vide**, voir plus bas | | `matches` | case, variante, graine, vainqueur, mode de victoire, tick et hachage finals | | `match_players` | les participants d'un match : siège, compte, faction, issue, pillage | | `match_orders` | **le journal d'ordres complet** de chaque match | | `monuments` | case, nom du bâtisseur, date (`monde.md`) | | `tuning` | la table de `valeurs.md`, modifiable sans redéploiement | | `settings` | réglages internes du serveur : secret de signature, graine du monde | Deux détails qui valent une ligne : - la clé primaire de `match_orders` est `(match_id, joueur, séquence)` — **la base refuse** deux fois le même numéro de séquence pour un joueur. La règle du tri total tient jusque dans le stockage, pas seulement en mémoire ; - `matches` garde le **hachage final** : un rejeu du journal qui ne le retrouve pas est un bug de déterminisme, et on le voit sans avoir à rejouer la partie en entier. ### ✅ Une carte = une graine + des deltas On ne stocke jamais un terrain. On stocke la **graine** qui le génère et la **liste des modifications persistantes**. Une variante de carte pèse quelques centaines d'octets au lieu de 70 Ko, et le générateur reste la seule définition du monde. Les 100 graines ne sont pas tirées au hasard une fois pour toutes : elles se **dérivent** de la graine du monde, de la case et de la faction (`variantSeed`, `server/grid.ts`). Perdre la base et la refaire redonne exactement le même monde. ### ✅ Ce qui persiste d'un match au suivant : le Monument, et lui seul Entre deux parties sur la même case, **seul le Monument survit** (`monde.md`) — avec le nom de son bâtisseur. **Tout le reste disparaît** : trous creusés, canaux, obsidienne, bâtiments, ruines. La case repart de la variante propre correspondant à sa faction propriétaire. Conséquence sur le sens du jeu, assumée : la permanence du monde se joue sur **qui possède quoi** et sur **les Monuments**, pas sur les cicatrices du terrain. Une partie est un affrontement sur un terrain propre ; c'est la carte du monde qui garde la mémoire. Conséquence technique, appréciable : `cell_deltas` n'a rien à stocker du terrain. Une case rejouée = `generate(graine de la variante active)`, et c'est tout. ### ✅ Le journal d'ordres est gardé Conserver `match_orders`, c'est obtenir gratuitement, puisque la simulation est déterministe : le **rejeu** d'une partie, le **mode spectateur**, la reproduction fidèle d'un bug signalé, et la possibilité de **rejouer tout l'historique** du monde après un correctif de moteur. --- ## 10. Tests - **Rejeu déterministe** — l'essentiel de la suite : un journal d'ordres, un hachage d'état attendu au tick `N`. Rapide à écrire, rapide à exécuter, et ça attrape les régressions de gameplay qu'aucun test unitaire ne verra. - **Pureté de `sim/`** — un test qui échoue sur tout identifiant interdit ou import illégal. - **Unitaires** sur les briques délicates : virgule fixe, écoulement, champs de flux. - **Captures headless** par jalon, avec échec sur erreur console, en réutilisant `shot.mjs`. Et, comme le rappelle `terrain.md` : **regarder réellement les captures**. --- ## 11. Ce qui n'est pas tranché ici - **Pente maximale franchissable** — bloque le pathfinding. Voir `questions-ouvertes.md`. - **Vitesse de propagation des fluides**, et toutes les valeurs de `valeurs.md`. - **Simulation dans un Web Worker** — reporté au profilage. - **Ce qui persiste d'un match au suivant** — proposition ci-dessus, à confirmer. - **Plusieurs processus serveur** — non nécessaire au départ, ne doit pas être empêché. - **Mode spectateur et rejeu en jeu** — le journal d'ordres est conservé et rejouable (vérifié en test), mais aucune interface ne s'en sert encore. - **Révocation d'une session isolée** — impossible avec un cookie signé sans table de sessions.