---
name: tts-integration
description: Intégration Tabletop Simulator (TTS) de mc4db-2.0 — export JSON côté backend (decks, packs, scénarios) et import Lua côté TTS (scripts dans mc_tools). À charger avant toute tâche touchant l'export TTS, le proxy d'images, les dos de cartes, les scénarios UUID, ou les scripts d'import Lua. Cerebro est obsolète.
---

# Intégration Tabletop Simulator (TTS)

Chaîne complète : **le backend mc4db génère du JSON au format objets TTS** → **des scripts Lua dans Tabletop Simulator récupèrent ce JSON par `WebRequest.get` et instancient les cartes** dans un sac (`Custom_Model_Bag`) posable sur le tapis.

- **Export** = code JS dans `mc4db-2.0` (source de vérité du format).
- **Import** = scripts Lua dans `mc_tools/TTS Script/` (à copier dans un objet TTS).
- **Cerebro (`Cerebro merlindumesnil.lua`) est OBSOLÈTE** — c'est l'ancien importeur basé sur MarvelCDB (by Hitch). Ne pas l'utiliser comme référence ni le maintenir. Les scripts actifs sont `SHIELD *.lua`.

---

## 1. Export — Backend

Tout est dans **`backend/src/routes/tts.routes.js`** (~1570 lignes, un seul fichier). Monté sans préfixe via `router.use(ttsRoutes)` dans `routes/index.js`, lui-même monté sur `/api/public` dans `index.js`. Donc **toutes les routes sont sous `/api/public/tts/...`**.

### Endpoints

| Méthode | Route | Rôle |
|---|---|---|
| GET | `/api/public/tts/deck/public/:id` | Decklist publique → piles TTS |
| GET | `/api/public/tts/deck/private/:id` | Deck privé (si `user.is_share_decks`) → piles TTS |
| GET | `/api/public/tts/pack/:code` | Toutes les cartes d'un pack → piles TTS |
| GET | `/api/public/tts/scenario/:uuid` | Scénario (UUID → sets) → piles TTS |
| GET | `/api/public/scenario-uuid/:uuid` | Lookup léger : renvoie juste les set codes (pas de TTS) |
| POST | `/api/public/tts/scenario` | Persiste un mapping `uuid → sets` dans `scenario_uuid` |
| GET | `/api/public/tts/card-image` | **Proxy image** : fallback langue + placeholder garanti |
| GET | `/api/public/tts/rotate-image` | Idem + rotation 90° (cartes paysage) |

**Query params communs** : `lang` (dossier image, défaut `fr`), `variant` (`promo` / `errata` / `alt-ffg`, optionnel).

### Format de sortie

```jsonc
{ "deckName": "...", "deckId": 42, "piles": { /* voir ci-dessous */ } }
```

Chaque pile est un **objet TTS natif** : `CardCustom` (1 carte) ou `DeckCustom` (>1 carte, avec `DeckIDs` + `ContainedObjects`). Piles possibles selon l'endpoint :
- Deck : `hero`, `mainDeck`, `permanents`, `sideDeck`, `nemesis`, `specialDecks{}`, `linkedDecks{}`.
- Pack : `hero`, `villains`, `mainSchemes`, `mainDeck`, `permanents`, `specialDecks{}`, `encounterSets{}`, `encounterPermanents`.
- Scénario : `villains`, `mainSchemes`, `encounterSets{}`, `encounterPermanents`.

### Concepts clés (helpers)

- **`buildTTSCard(card, deckId, lang, opts)`** — construit un objet carte unique. `CardID = deckId*100`, `CustomDeck[deckId] = { FaceURL, BackURL, ... }`, `Transform` par défaut (`rotY:180, rotZ:180`, dos caché). Compteur `nextDeckId` incrémenté globalement par réponse pour garantir des IDs uniques.
- **`buildTTSPile(slots, detailsMap, nextDeckId, lang, ...)`** — transforme des `{code, quantity}` en `CardCustom`/`DeckCustom`. Gère : quantités (expansion physique), variantes multi-états, skip des dos.
- **`resolveBackURL(card, lang)`** — priorité stricte du dos : (1) recto→verso lié / double-face, (2) `villain_back`, (3) `encounter_permanent`, (4) `encounter_back`, (5) `setup_player_card`, (6) `player_back`. Les dos statiques viennent de `BACKS_BASE_URL` (`bundles/TTS/cards_back/*.webp`).
- **`buildHeroIdentityCard(...)`** — le héros = **une seule carte double-face** : recto `…b` (hero), verso `…a` (alter-ego). HP écrit dans `Description` pour lecture Lua. Les formes multiples (`c`/`d`/`e`) deviennent des **`States` TTS**.
- **`fetchNemesisAndSpecialSets(heroCode)`** — trouve dynamiquement Obligation (set du héros, type `obligation`) + Nemesis (Cardset enfant `cardset_type='nemesis'`, fallback `<set>_nemesis`) + decks spéciaux (`cardset_type='hero_special'`, groupés par set → une pile chacun).
- **`fetchLinkedCardSlots(codes)`** — inclut auto les cartes « Linked (Nom) » / « Liée (Nom) » dont le nom matche une carte du deck (ex. Bouclier de Captain America). Retire le suffixe de type via les traductions EN+FR.

### Conventions de codes cartes (IMPORTANT)

- Suffixe `a` = recto, `b` = verso (dos). Un `b` avec un `a` correspondant est **skippé** (sert de dos).
- Suffixes `c`/`d`/`e` = formes alternatives → attachées comme **`States`** sur la carte de base, jamais des piles séparées.
- `LANDSCAPE_TYPES` = `main_scheme`, `side_scheme`, `player_side_scheme` → image passée par `rotate-image` + `SidewaysCard:true`.

### Proxy d'images & cache-busting

- **Jamais d'URL image directe dans le JSON** : tout passe par `/tts/card-image` ou `/tts/rotate-image`. Raison : TTS ne gère pas les 404/HTML → le proxy garantit toujours un `image/webp` valide (sinon placeholder `player_back.webp`).
- **Chaîne de fallback** (`fetchImageWithFallback`) : variant-lang → lang → variant-EN → EN → duplicate original → placeholder.
- **Cache-busting** : `image_versions.json` (`bundles/cards/image_versions.json`) donne un suffixe `&v=N` par image modifiée (v0 = pas de suffixe). Rechargé toutes les `IMAGE_VERSIONS_RELOAD_HOURS` (défaut 24 h). Voir aussi `[[deploy-cache-assetversion]]`.
- Constantes en tête de fichier : `CARDS_BASE_URL`, `BACKS_BASE_URL`, `API_BASE_URL` (`BASE_URL` env, défaut `http://127.0.0.1:4000`).

### Scénarios & Mission Codes

- Table **`scenario_uuid`** : `uuid` (code mission 12 car.) → `villain_set_code`, `modular_set_codes` (JSON), `standard_set_code`, `expert_set_code`.
- Front : `ScenarioStatsSidebar.jsx` (`ScenarioUUIDRow`) **POST** le mapping à chaque affichage d'un scénario (persistance passive) et affiche le « mission code » copiable. `FreePlayTab.jsx` fait un GET `/scenario-uuid/:uuid` (input 12 car.) pour restaurer une sélection.
- Métadonnées injectées pour le Lua : villain `stage`→`Memo`, HP→`Description` (`HP:x|per_hero:true`) ; main scheme `stage`→`Memo`, menace de départ→`Description` (`Threat:x|fixed:true/false`).

### Déclencheurs côté web app

Pas de bouton « exporter en TTS ». L'UX = **copier l'ID** puis le coller dans le script Lua :
- Deck : bouton « Copier le numéro du deck (pour import TTS) » dans `DeckView.jsx`.
- Scénario : « mission code » copiable dans `ScenarioStatsSidebar.jsx`.

---

## 2. Import — Scripts Lua (mc_tools)

Répertoire : **`C:\github\mc_tools\TTS Script\`**. Chaque script se colle dans le champ *Lua Script* d'un objet TTS (une tuile/bloc) qui devient un panneau d'import avec UI 3D (boutons créés via `self.createButton`/`createInput`).

| Fichier | Rôle | Endpoint appelé |
|---|---|---|
| `SHIELD deck script.lua` | Importe un deck joueur (public/privé) | `/deck/public/:id`, `/deck/private/:id` |
| `SHIELD pack import.lua` | Importe un pack complet | `/pack/:code` |
| `shield scenario import.lua` | Importe un scénario | `/scenario/:uuid` |
| ~~`Cerebro merlindumesnil.lua`~~ | **OBSOLÈTE** (ancien, basé MarvelCDB) | — |

### Fonctionnement commun

1. UI 3D : sélecteur langue (EN défaut) + variantes (Promo/Errata/Alt FFG) + input ID + bouton import.
2. `fetchData(endpoint)` construit l'URL `API_BASE_URL .. endpoint .. "?lang=" .. lang [.. "&variant=..."]` et appelle `WebRequest.get(url, onApiResponse)`.
3. `onApiResponse` : `JSON.decode`, puis `gatherAndSort(data.piles or data)` parcourt récursivement l'arbre, repère les nœuds `DeckCustom`/`CardCustom` et les collecte. **Deck script (refondu)** : le tag/placement/échelle vient du backend (cf. §3.4), le Lua ne fait plus que collecter + extraire image/PV héros. **Pack & scénario (ancien)** : le Lua tague encore chaque pile via `GMNotes` (`Villain`, `MainScheme`, `Encounter`, `Permanent`…) et applique les échelles.
4. Tout est empaqueté dans un `Custom_Model_Bag` (`spawnObjectJSON`) avec un **script de sac injecté** (`getBagScript`) qui ajoute un bouton « Placer sur le Tapis » / « Place » déployant les objets aux bonnes coordonnées selon leur `gm_notes`.

### Points notables par script

- **Deck** (`SHIELD deck script.lua`) : injecte un **compteur de PV** (`Custom_Tile` + `COUNTER_SCRIPT` Lua) initialisé avec les HP lus dans `Description` du héros. Le sac prend l'image du héros (`FaceURL`). Placement par siège couleur (`matLocation` Yellow/Red/Blue/Green) via le moteur générique (§3.4).
  - **Outils dev** : `IS_DEV` (= vrai si `SERVER_BASE_URL` local/127.0.0.1) gate le bouton **DBG** (dump ancres/snap points) et les prints de debug → masqués en prod.
  - **Versionnage** : variable `SCRIPT_VERSION` en tête, format **`AAAA-MM-JJ.N`** (date + n° de mise à jour du jour ; N repart à 1 chaque nouvelle date). Affichée sur l'importeur (bouton non interactif `noop`, zone sombre sous les boutons d'import), préfixée du nom d'objet `OBJECT_NAME` (ex. « Deck Importer v2026-07-31.9 »). **À incrémenter à chaque modif du script** — repère en cas de problème/rollback.
- **Pack** (`SHIELD pack import.lua`) : trie en buckets (hero/player/nemesis/villains/encounter…), sac = tuile `bundles/TTS/tuile.obj` + image `bundles/TTS/packs_front/<code>.webp`. Bouton « Placer/Ranger » qui étale/range les piles.
- **Scénario** (`shield scenario import.lua`) : lit `data.name` (nom du vilain). Le sac calcule la **menace de départ** : `Threat:x` × nombre de joueurs assis (sauf `fixed:true`) et pousse la valeur au compteur de menace GUID `0b3ca7`. Coordonnées de déploiement fixes (villain/mainScheme/permanent/encounter).

### URL du serveur dans les scripts

Bloc de config **identique en tête des 3 scripts actifs** (TTS/Lua ne peut pas lire de variable d'env → toggle manuel) :

```lua
local SERVER_BASE_URL = "http://127.0.0.1:4000"                -- DEV (local)
-- local SERVER_BASE_URL = "https://mc4db.merlindumesnil.net"  -- PROD
local API_BASE_URL = SERVER_BASE_URL .. "/api/public/tts"
```

**Par défaut = DEV (local)**. Pour déployer une version finalisée : commenter la ligne DEV, décommenter la ligne PROD, dans les 3 scripts. `SERVER_BASE_URL` sert aussi aux assets du pack (`/bundles/TTS/packs_front/<code>.webp`, `/bundles/TTS/tuile.obj`).

---

## 3. Système de coordonnées TTS (validé visuellement sur le script deck)

Le script deck jongle avec **quatre référentiels distincts** — les confondre = tout casser.

### 3.1 Axes du monde (absolus)
- **+X = droite**, **+Z = loin (vers le vilain)**, **+Y = hauteur**. Unités TTS (la table fait ~90 de large).
- Sièges joueurs sur `z=-19.15` (bord proche) ; zone vilain/scénario en `+z`.
- 4 sièges (`matLocation`, coord. **absolues codées en dur**) : `Yellow x=-43.20`, `Red x=-14.40`, `Blue x=+14.40`, `Green x=+43.20`, tous `y=0.98, z=-19.15`. Cartes posées à `y=1.1`.

### 3.2 UI locale de l'objet (`createButton`/`createInput` `position`)
Widgets 2D sur la face de l'objet, en **espace local** : `{x, y, z}` avec `x`=gauche(−)/droite(+), `y`=hauteur au-dessus surface (~0.1), `z`=**haut(−)/bas(+)** de la face. `width/height/font_size` en unités raster UI (pas monde) ; `scale` multiplie taille+espacement. Ces coords suivent l'échelle/rotation de l'objet.

### 3.3 Local → Monde (`self.positionToWorld{...}`)
Convertit un point local de l'importeur en absolu (applique position+rotation+échelle). Sert au **spawn du sac** à côté de l'importeur : `positionToWorld({offsetX, 0, 2.5})`, `offsetX=-1.75+deckCount*1.75` (chaque deck décalé), `posY+=1.5`, `rotY=self.getRotation().y`. **Spawn portable** — marche où qu'on pose l'importeur. Le **layout multi-deck fonctionne** via ce décalage `deckCount`.

### 3.4 Placement sur le tapis — **descripteur backend-driven** (bouton « Place @ Mat »)
**Architecture (refonte)** : le script Lua ne contient **plus** de table de positions. Chaque pile porte son placement dans son **`GMNotes`** sous forme de **JSON émis par le backend** :

```json
{ "tag":"Identity", "dx":-10.63, "dz":2.32, "rot":[0,180,180], "scale":1.65 }
```

Le bouton « Place @ Mat » est un **moteur générique** : pour chaque objet du sac il fait `JSON.decode(gm_notes)`, puis pose à `origine_du_siège + (dx, dz)`, `y=1.1`, avec `rot` et `setScale({scale,1,scale})`. Cas spécial : `tag=="Counter"` → appelle `createAll()` après spawn.

**Conséquence clé : ajouter/déplacer une pile = uniquement le backend** (`tts.routes.js`), zéro modif Lua. Côté backend, helper `placement(tag, dx, dz, rot, scale)` (renvoie le JSON) posé sur `pile.GMNotes` dans `buildDeckTTSResponse`.

Deltas actuellement émis (relatifs au siège ; `−x`=gauche, `+z`=vers le vilain, `−z`=vers le joueur) :

| tag | dx, dz | rot | scale | Position |
|---|---|---|---|---|
| `Counter` | −10.62, +6.0 | [0,180,0] | 1.13 | haut-gauche (injecté par le Lua) |
| `Identity` | −10.63, +2.32 | [0,180,**180**] | 1.65 | gauche, sous le compteur |
| `Deck` | +6.6, +5.44 | [0,180,0] | 1.0 | haut-droite |
| `Nemesis` | −8.34, +10.88 | [0,180,0] | 1.88 | au-dessus du tapis |
| `Permanent` | −6.65, −1.94 | [0,180,0] | 1.0 | bas-gauche |
| `SideDeck` | +6.6, −1.94 | [0,180,0] | 1.0 | bas-droite |
| `Special`/`Linked` (×N) | 6.6 − col·2.65, −1.94 | [0,180,0] | 1.0 | rangée de l'annexe, 1 case/pile (colonne partagée) |

`Special`/`Linked` sont à **cardinalité variable** et partagent **une seule rangée** (celle de l'annexe/SideDeck) : compteur de colonne `extraCol` **partagé** entre les deux boucles (backend), `dx = 6.6 − extraCol·2.65`, `col 0` = l'annexe. Aucune superposition.

**Grille du playmat joueur (importante).** Deux grilles distinctes coexistent :
- des **snap points** (objet de grille à ~37 points près du siège, `getSnapPoints`) à **pas ≈ 4.17** (`dx = -8.33, -4.16, 0, 4.17, 8.34…`) — le "Marvel Champions Playmat" nommé, lui, renvoie 0 snap ;
- les **cases dessinées** (corner-brackets) à **pas 2.65**, qui sont celles où on veut poser les piles.

On **ne snappe pas** : on place aux `dx/dz` calés sur les **cases dessinées** (2.65), pas sur les snap points (4.17). Pas de la grille des cases dessinées, **tiré des positions tunées à la main** de l'ancien script Lua (`cardDelta`) :
- **Pas colonnes (x) = 2.65** (unique `-6.65` → unique2 `-4.0` ; et `6.6 = -4.0 + 4·2.65`).
- **Pas rangées (z) ≈ 3.68** (main `z=5.44` → annexe `z=-1.94` = 7.38 = 2 rangées).
- Colonnes de cases sur la rangée `z=-1.94` : `… -6.65, -4.0, -1.35, 1.30, 3.95, 6.6 …`.
- `EXTRA_PITCH=2.65` dans `buildDeckTTSResponse`. (Identity/counter à `x≈-10.6` sont hors de cette grille — emplacement dédié alter-ego, non aligné aux cases joueur.)

### 3.5 Rotations / faces (décidées par `rot` du descripteur backend)
- `Identity` `rot=[0,180,180]` → posée **côté alter-ego lisible** (le `+180` en Z montre le `BackURL`).
- Le reste `rot=[0,180,0]` → pioche/piles face cachée (dos MC).
- C'est `rot` du descripteur qui décide de l'orientation finale, **pas** le `Transform` du JSON (le placement écrase la rotation).

### 3.6 Anciens tags `Unique`/`Unique2` — SUPPRIMÉS
Remplacés par les tags nommés `Permanent`/`SideDeck`/`Special`/`Linked` (cf. 3.4). Historique : les permanents, side, special et linked partageaient 2 emplacements fourre-tout `Unique`/`Unique2` et se superposaient au-delà de 2 piles. Ne plus utiliser.

### 3.7 UI XML d'objet (`self.UI`) — 5ᵉ référentiel, NON STANDARD (validé sur `control_panel`)

L'UI XML **attachée à un objet** (`self.UI.setXml(xml)`, panneau 2D flottant, distinct de l'UI globale plein-écran) a ses propres règles, apprises en jeu sur `bundles/TTS/tools/control_panel.json`. **À connaître avant tout panneau d'objet.**

- **`setXml` est DIFFÉRÉ (async)** : après `self.UI.setXml(...)`, les éléments ne sont PAS interrogeables la même frame → un `self.UI.setAttribute(id, …)` immédiat lève `Object reference not set to an instance of an object` (l'`id` n'existe pas encore). **Patron** : au 1er affichage, **baker** l'état voulu (`active="…"`) DANS le XML construit et ne faire QUE `setXml` ; réserver `setAttribute` aux appels ultérieurs (garde `uiBuilt`). ⚠ toute variable Lua lue par le constructeur XML (ex. `panelShown`) doit être déclarée AVANT lui (portée `local`). (Cf. aussi §4quater.)
- **L'UI hérite de la ROTATION de l'objet** : sur une tuile posée avec `rot=[0,180,0]` (cas des placements mc4db, cf. §3.5), le panneau rend **tête en bas**. Correction = **contre-rotationner le contenu** avec l'attribut `rotation` du `<Panel>` racine. ⚠ **l'axe de « roulis » (rotation dans le plan) d'une UI 2D est Z, PAS Y** : `rotation="0 0 180"` remet le texte à l'endroit (un `rotation="0 180 0"` n'a **aucun** effet visible sur un panneau à plat).
- **L'UI hérite de l'ÉCHELLE (non-uniforme) de l'objet** : une tuile aplatie (`setScale{sx, 0.2, sz}` via `sizeToWorld`) **écrase** tout panneau **vertical** (sa hauteur mappe l'axe Y = 0.2). → **garder le panneau À PLAT** (dans le plan XZ de la tuile, aucune rotation autour de X). Un panneau vertical lisible imposerait de ne pas aplatir la tuile (compromis visuel).
- **GOTCHA ordre placement vs `sizeToWorld`** : le moteur « Place @ Mat » fait `setScale({scale,1,scale})` (Y=1) **APRÈS** avoir appelé `mc4dbSetSeat` → un `sizeToWorld()` synchrone dans le hook est **annulé** (la tuile redevient un cube). **Fix** : relancer `sizeToWorld` en **différé** dans `mc4dbSetSeat` (`Wait.time(function() sizeToWorld() end, 0.35)`) pour passer après le `setScale` du placement.
- **`position` du `<Panel>` racine** : unités « pixel » (mêmes que `width`/`height`), appliquées dans le repère UI de l'objet **avant** la rotation du contenu. Sert à **glisser le panneau à plat** sur la tuile. Un panneau à plat rend **dans le plan de la surface** de la tuile, et le **bouton 3D** (`createButton`) est légèrement **au-dessus** → sans décalage, le bouton masque/bloque le centre du panneau. → **décaler le panneau** (ex. `position="0 -320 0"`, ~¾ de sa hauteur) pour dégager le bouton. Le **signe/axe du décalage dépend de la rotation** appliquée → régler en jeu.
- **Fermeture** : recliquer le bouton 3D qui a servi à ouvrir suffit (toggle) → **pas besoin d'un bouton ✕** dans le XML (redondant).

---

## 4. Assets & tables

- **Dos de cartes** : `mc4db-2.0/bundles/TTS/cards_back/` → `player_back`, `encounter_back`, `encounter_permanent`, `setup_player_card`, `villain_back` (`.webp`).
- **Faces de packs** : `bundles/TTS/packs_front/<code>.webp` (image du sac de pack).
- **Mesh sac** : `bundles/TTS/tuile.obj`.
- **Recto/verso cartes** : `bundles/cards/<LANG>/<pack>/<code>.webp` (+ dossiers variantes `promo-FR`, `errata-EN`, `alt-FFG-FR`…).
- **DB** : tables `card`, `pack`, `type`, `faction`, `Cardset`, `Cardsettype`, `card_translation`, `scenario_uuid`, `tts_tool`, + slots deck (`decklistslot`/`sidedecklistslot`, `deckslot`/`sidedeckslot`).
- **Mod de référence (Hitch)** — le save TTS où l'utilisateur ajoute les objets : `C:\Users\nicol\Documents\My Games\Tabletop Simulator\Mods\Workshop\2514286571.json` (~48 Mo). **Y chercher les vraies coordonnées/échelles** (parser en Node, ne pas lire en entier). Repères extraits : cartes de statut (Tough/Stunned/Confused) = `CardCustom` **échelle 0.5** ; carte joueur normale = échelle 1 ; vilain/schéma = 1.88. `ObjectStates[]` = objets racine ; enfants dans `ContainedObjects`/`States`.

---

## 4bis. Outils / plugins (backend-driven)

Système générique pour **ajouter des objets TTS actifs** (contrôleurs, plateaux, jetons) à la table selon le **héros / pack / scénario**. Même philosophie que les piles : **le backend décide, le Lua est un moteur générique**.

### Stockage (option C — hybride)
- **Objet TTS** = fichier `bundles/TTS/tools/<tool_key>.json` (save TTS complet : `Name`, `Transform`, `LuaScript` éventuel…). **Porteur de logique (Lua) → versionné git** via exception `.gitignore` ciblée (`!/bundles/TTS/` + `/bundles/TTS/*` + `!/bundles/TTS/tools/`), car `bundles/*` est ignoré (assets déployés hors git). **GOTCHA** : tout nouvel outil doit être `git add` (le reste de `bundles/TTS/` reste ignoré). Le backend **lit le fichier sur disque et l'inline** dans la réponse — TTS ne le télécharge jamais directement (pas besoin d'expo statique).
- **Mapping** = table **`tts_tool`** (créée par `import:all`, `CREATE TABLE IF NOT EXISTS` + `ALTER` idempotents) : `trigger_type` (`hero`/`pack`/`scenario`), `trigger_code`, `tool_key`, `dx,dz,rot_x/y/z,scale,tool_count,needs,image,position,enabled`.
- **Seed du mapping** = `DataImport/data/tts_tools.json` → importé (idempotent, upsert sur `trigger_type+trigger_code+tool_key`).
- **Image (option)** : colonne `image` = nom de fichier dans `bundles/TTS/tools/<image>` (versionné git). Si renseignée, le backend transforme l'objet en **`Custom_Tile`** (carte plate) avec `CustomImage.ImageURL = ${API_BASE_URL}/bundles/TTS/tools/<image>?v=<mtime>` (servi par le backend, marche dev+prod). Sinon l'objet du fichier est laissé tel quel (ex. `BlockSquare`). **Cache-bust TTS** : le suffixe `?v=<mtime>` (date de modif du fichier, `fs.statSync`) force TTS à recharger l'image quand on remplace le fichier (même nom) — sinon TTS ressert sa copie en cache. Placeholder généré via `sharp` (SVG→webp, ratio ~1.4:1). Voir aussi [[deploy-cache-assetversion]].
- **Ajouter un outil = 1 fichier + 1 ligne** dans `tts_tools.json`, puis `import:all` (+ `git add` du fichier objet et de l'image éventuelle).

### Contrat API
La réponse d'import deck gagne un champ **additif** `tools[]` :
```jsonc
"tools": [
  { "objectJson": { /* save TTS complet, GMNotes déjà baké */ },
    "toolKey": "magik_top_card", "needs": ["Identity","Deck"] }
]
```
- `resolveTools(triggerType, codes)` (dans `tts.routes.js`) : lit `tts_tool`, charge le fichier via `loadToolFile()` (garde anti path-traversal `[A-Za-z0-9_-]+`), et **bake le placement** dans `objectJson.GMNotes` = `{tag:"Tool", tool_key, dx, dz, rot, scale, needs}` (même canal que les piles). `tool_count>1` → N copies décalées de 2.65 en X.
- Déclencheur **hero** = match sur le **`card_set_code`** du héros (ex. `magik`) **ou** le code de base (`hero_code` sans suffixe a/b/c).
- Câblé dans `buildDeckTTSResponse` (deck seulement pour l'instant ; pack/scénario à venir).

### Moteur Lua (générique, zéro logique par outil)
- `SHIELD deck script.lua` / `onApiResponse` : pousse chaque `data.tools[i].objectJson` dans `finalCollection` → l'outil transite par le **même sac** et le **même « Place @ Mat »** que les piles (placement via son `GMNotes`).
- Hook **`mc4dbSetSeat`** : au placement, le moteur du sac appelle `spawned.call("mc4dbSetSeat", {color,x,y,z})` pour tout objet `tag=="Tool"` → l'outil connaît **l'origine de son siège** (sans effet s'il n'implémente pas la fonction). Indispensable car les piles à rattacher (Identity/Deck) sont à l'opposé du siège : on découvre **la pile taggée la plus proche de l'origine du siège** (rayon ~14 ; sièges espacés de 28.8 → isolation propre), **pas** la plus proche de l'outil.

### Interaction avec la table
- **2.a — découverte par tags** (`GMNotes`) : l'outil retrouve les piles par `tag` (`Identity`, `Deck`, …) ancré sur l'origine du siège injectée. Timing géré par **polling** (`Wait.time(tick,…,-1)`), car les piles n'existent qu'après « Place @ Mat ».
- **2.c — station board** (outils à état) : un plateau embarque sa géométrie + son script.

### Premier outil : `magik_top_card`
Gabarit d'outil **actif**. `BlockSquare` fin (couleur 0) qui :
- **s'auto-positionne SOUS la carte identité** (`positionUnderIdentity` : cible `identity.pos + (0,0,-LIMBO_BELOW)`, puis **snap sur l'ancre la plus proche** via `nearestSnapWorld` — les slots de statut sous l'identité **ont des snap points** exploitables, contrairement aux cases des piles §3.4). Attend la fin de la glisse (`isSmoothMoving`), pose, puis **verrouille** (`setLock(true)` à +0.3 s) → on peut empiler les cartes d'état dessus ;
- **taille = carte de statut** : les statuts (Tough/Stunned/Confused) sont des `CardCustom` **échelle 0.5** (source infinite bag 0.7), soit ≈ **1.75×1.25** en monde. Dimensionnement via **`sizeToWorld()`** (mesure `getBounds()` réels → calcule l'échelle) : **indépendant du type d'objet** (BlockSquare vs Custom_Tile n'ont pas la même taille de base). Constantes isolées en tête : `LIMBO_SIZE={1.75,1.25}` (monde), `LIMBO_THICK`, `LIMBO_BELOW` (décalage -Z sous l'identité), à affiner ;
- **surveille la face** de l'identité (`is_face_down`; identité mc4db : `FaceURL`=héros, `BackURL`=alter-ego → `is_face_down=false`=héros), **révèle** la carte du dessus (`deck.takeObject{top,flip=true}`) en face héros, la **masque** en alter-ego. Rotations mc4db : **face cachée `{0,180,0}`**, **face visible `{0,180,180}`** ;
- **révélation idempotente** : la carte révélée est taguée `MagikRevealed` → plusieurs contrôleurs ne révèlent qu'UNE carte (anti-course), et le moteur du sac **déduplique** les outils du même `tool_key` près du siège (évite l'empilement au ré-import).

⚠ Logique `takeObject/putObject` sensible au timing → **valider dans TTS**.

### Déclencheur par mot-clé + marqueurs d'effet par carte (outil `card_effects`)
Second mécanisme de déclenchement, **basé sur le contenu du deck** (pas le héros) :
- **Marqueur d'effet par carte** : `buildTTSCard` bake dans le **`GMNotes` de chaque carte** un `{"effect":{…}}` selon `real_text` (helper `cardEffect`) : `Toughness` → `{tough:true}` ; `Uses (N …)` → `{counter:N}`. (« Toughness » = mot-clé en majuscule ; « tough status card » en minuscule ne matche pas.)
- **Trigger `keyword`** : `trigger_type='keyword'` (ENUM élargi). `buildDeckTTSResponse` inclut l'outil `card_effects` (via `resolveTools('keyword', ['card_effects'])`) **si le deck joueur contient ≥1 carte à effet**.
- **Assets embarqués** : un fichier outil peut déclarer `"_assets": ["tough","counter"]` (champ custom **strippé** par le backend). `resolveTools` charge `bundles/TTS/tools/assets/<key>.json` et les **inline dans `LuaScriptState`** (`{assets:{…}}`). Objets extraits du mod de Hitch (Tough = `CardCustom` 0.5 ; compteur cliquable = `Custom_Tile` avec `setThreat({val=N})`).
- **Outil `card_effects`** (slot **gauche** sous l'identité, locké, snap, singleton par siège) : applique l'effet **à chaque fois que la carte est jouée DEPUIS LA MAIN** (pas une fois). Mécanique : un poll suit la main du joueur du siège (`Player[SEAT.color].getHandObjects`) → marque `wasInHand[guid]` ; `onObjectDrop` applique si la carte **venait de la main**, n'y est plus, et tombe **dans la zone du siège** (`SEAT_RADIUS`) → **spawne** la Tough dessus / un compteur réglé sur N ; puis ré-arme (`wasInHand` reposé au prochain passage en main). Un simple repositionnement sur la table ne re-déclenche pas. Placement de la tuile `FX_DX`/`FX_BELOW` (comme Magik). ⚠ `onObjectDrop`/`getHandObjects`/`spawnObjectJSON` → **valider dans TTS**.

#### Gestion des positions des items SUR la carte jouée (points durs validés)
- **Relatif à la carte via ses axes propres, PAS `positionToWorld` local** : le décalage vertical mis en Y **local** part de travers car la carte jouée est retournée (`rotZ=180` → l'axe Y local n'est pas la verticale monde). Bon calcul = `anchorWorld(card,a)` : plan via `card.getTransformRight()` / `getTransformForward()` (horizontaux, suivent la carte) + **hauteur en Y MONDE**. `a = {droite(+)/gauche(-), hauteurY, avant(+)/arrière(-)}` en **unités monde** (bord droit ≈ 1.1, bas ≈ -1.5). **Repère observé** : axe *droite* → GAUCHE écran, axe *avant* → BAS (donc droite écran = valeur `-`, haut = `-`).
- **ATTENDRE LE REPOS de la carte avant de mesurer** (`whenRested` via `obj.resting`) : au `onObjectDrop`, la carte glisse/tourne encore → mesurer sa transform à cet instant donne une **position non constante**. C'était LE bug de fond.
- **Ancres** (constantes isolées, unités monde) : `ANCHOR_TOUGH` (statut, sur l'illustration), `ANCHOR_COUNTER` (uses, bas-droite, **rentré de ~½ largeur** car l'ancre CENTRE l'objet), `ANCHOR_DAMAGE` (miroir bas-gauche, pour jetons de dégâts posés à la main). Snap points ajoutés sur la carte (`setSnapPoints`) via `positionToLocal(anchorWorld)` → cohérents avec le spawn + posables à la main (grille active).
- **Tailles — VRAI GOTCHA = `CustomTile.Type`** (pas le `setScale`) : `TOUGH_FACTOR=0.5`/`COUNTER_FACTOR=0.6` via `setScale(s.x*factor)` au spawn marchent sur les tuiles **`CustomTile.Type 3`** (compteur « uses » vert) mais **pas** sur **`Type 2`** (l'ancien token « Damage » extrait du mod) : à Type 2, ni le `setScale` runtime ni la taille bakée dans le `Transform` ne réduisent le rendu (géométrie/bouton dominants). **SOLUTION : cloner le squelette ÉPROUVÉ `assets/counter.json` (Type 3) et n'y remplacer QUE l'image + `ColorDiffuse`** (ex. token dégâts = counter vert Type 3, image sombre, couleur sombre) → même dimensionnement fiable. **Ne jamais réutiliser tel quel un compteur `Type 2` du mod.** Le nombre affiché est un **bouton** créé par le script du compteur (`createAll`, `scale={1.5}`, `font_size=600`) qui suit l'échelle de l'objet **si Type 3**.
- **Compteurs** : `Snap=true` (accroche aux ancres de la carte, cf. `addAnchors`) + `Grid=false` (**pas** la grille du tapis) — Grid ≠ Snap. `Hands=false` + `Sticky=true` → l'item suit la carte quand on la soulève, sans partir en main.
- **Effet `damage` (alliés)** : `cardEffect` bake `{damage:true}` sur les cartes `type_code=='ally'` à PV → `applyDamage()` pose le **token « Damage » DÉDIÉ** (`assets/damage.json`, tuile sombre extraite du mod, `setThreat`, valeur 0) en **bas-gauche** (`ANCHOR_DAMAGE`). ⚠ Bug vécu : `applyDamage` encodait `ASSET_COUNTER` (vert) au lieu de `ASSET_DAMAGE` → vérifier le `JSON.encode(ASSET_*)` DANS le corps, pas juste le garde.
- **Tough = TUILE** (plus une carte) : `assets/tough.json` = `Custom_Tile` (reconstruit sur le squelette éprouvé de `counter.json`, Type 3, `Thickness 0.05`, image du Tough, `ColorDiffuse 1,1,1`). ⚠ **Isoler chaque application en `pcall`** dans le callback `whenRested` : un asset mal formé qui plante `applyTough` bloquait sinon `applyDamage` (symptôme « Colossus : ni tough ni PV », alors qu'Aero, sans tough, recevait son token).
- **PAS d'attachement** (`addAttachment`) : l'attachement TTS **fige** l'objet (compteur non cliquable), **déforme** le Tough (transform parent) et **suit la carte en main** → tout ça non voulu. Les items sont juste **posés** ; le nettoyage au discard est géré par une **tuile pile discard** séparée.

#### Retraits & confort
- **Menus contextuels** : « Remove » sur le Tough et le compteur (script embarqué dans l'asset ; pour le compteur, wrapper autour de son `onload` Hitch existant, sans le casser). « Remove Tough » sur la **carte** (via `addContextMenuItem`, une fois par GUID → `cardMenuAdded`), retire le Tough le plus proche.
- **Hotkeys** (`addHotkey`, à binder dans *Options → Game Keys*, **pas de touche par défaut** possible ; alternative = scripting buttons max 10) : « MC4DB: Remove (hover) » (item survolé) et « MC4DB: Remove Tough » (Tough le plus proche du survol). Enregistrés **une fois** (registre `Global`).
- **Debug (DEV only)** : `debug:true` injecté dans `LuaScriptState` quand `IS_DEV` (backend local) → `DEBUG_ANCHORS`. Cubes marqueurs colorés (jaune=statut, cyan=token, rouge=dégâts) **temporaires** (`DEBUG_MARKER_TTL`), + `cleanupDebugMarkers` au chargement (purge les résiduels). Les **snap points restent** après disparition des cubes.
- **Tampon de build** : `built` (timestamp ISO) injecté par le backend, affiché à l'import → vérifier la fraîcheur (diagnostic cache/version ; le Lua n'est PAS caché par TTS, seules les images le sont via `?v=<mtime>`).

**GOTCHA source Lua** : comme Magik, le Lua de `card_effects` (et des assets) **vit embarqué dans le JSON** (`bundles/TTS/tools/…`). Pour l'éditer : extraire, modifier, ré-embarquer (JSON.stringify). Les sources `.lua`/wrap ne sont pas versionnées séparément.

---

## 4ter. Station deck (draw/discard) + outils composables + archi PHASE

Chantier majeur (2026-08-01, validé en jeu). Le deck du joueur repose sur une **station** (plateau draw/discard) autour de laquelle des **extensions** se greffent. Trois nouveaux concepts transverses.

### Type de trigger `generic` (outils sur TOUS les decks)
- ENUM `tts_tool.trigger_type` élargi à **`generic`** (DDL + ALTER dans `import-all.js`). `trigger_code='deck'` (extensible `pack`/`scenario`). `buildDeckTTSResponse` appelle toujours `resolveTools('generic',['deck'])` → outils sur tous les decks. Câblé aussi dans l'UI admin (`TTS_TRIGGER_TYPES`/`TRIGGER_TYPES`/i18n `tts_trigger_generic`, badge orange).

### Ordre de spawn = `phase` (GOTCHA `takeObject`)
- **GOTCHA fondamental** : TTS `takeObject` dépile un sac dans l'**ordre d'INSERTION, PAS selon le `guid`** passé. Donc réordonner les appels `takeObject` → chaque objet reçoit la position d'un AUTRE (symptôme : « deck à la place de l'identité »).
- Solution : l'ordre se règle **À L'ASSEMBLAGE** (tri des `ContainedObjects` par `phase`, sur une table Lua), puis **placement en UNE passe** dans l'ordre du sac. `phase` bakée dans GMNotes par `resolveTools` (`0`=outils structurels génériques avant les piles, `1`=piles [défaut], `2`=outils dépendants après les piles). **Ajouter un objet = lui donner une phase, zéro branche de dispatch.**

### Plateau `draw_discard_board` (générique, 2 slots)
- Custom_Tile fine : DISCARD (gauche) | PIOCHE/DRAW (droite). Deck posé sur la case draw (position **dérivée** de la ligne `tts_tool` du plateau : `resolveDeckDrawSlot()` = `board.dx + BOARD_SLOT_RATIO(0.6625)·scale` → bouger/retailler le plateau via l'admin propage au deck). Slots dérivés des **bounds réels** (`SLOT_FRAC=0.237`) ; snaps draw/discard portés par le plateau ; `Grid/Snap/grid_projection=false` (drag libre). API `mc4dbGetSlot("draw"|"discard")`/`mc4dbSlotPitch()` pour les extensions.
- **Action Discard** (hotkey `MC4DB: Discard (hover)` + menu contextuel) → carte sur la case discard + **nettoyage CHIRURGICAL** des marqueurs (`cleanupMarkersOn` : uniquement Nickname `Tough`/`Counter`, **JAMAIS un conteneur** `Infinite/Bag/Deck`, tight radius, Y au-dessus). `onObjectDrop` ne nettoie que si la carte est **sur l'ancre discard** (boîte serrée) + flip face avant.
- **Calage deck** : `snapDeckToDraw()` (poll après pose) recale le deck (tag "Deck") pile sur la case draw.
- **Reshuffle auto** : `checkAutoReshuffle()` (poll, debounce 2) — pioche **et** reveal vides + défausse pleine → défausse remise en pioche face cachée (anim **fast**) + `deck.shuffle()`, **puis** `dealEncounterForSeat()` = déclenche `drawEncounter` (bouton « Deal Encounter Card » du mod Hitch) du plateau le plus proche du siège. Reshuffle manuel aussi : carte **face visible** posée sur draw → face cachée + shuffle.

### Extension `magik_reveal` (hero=magik) — refonte de Magik
- Tuile séparée qui se **dock à droite de la case draw** (`DOCK_MULT`·slotPitch), surveille la face de l'identité, révèle la carte du dessus **sur elle-même** (recto)/la remet sur la draw. **Source la carte À LA POSITION du slot draw** (pas de réf à l'objet deck) → **robuste au deck qui se vide** (DeckCustom→Card→vide). Expose `mc4dbRevealSlot()`. Remplace l'ancien `magik_top_card` (déprécié, fichier gardé).

### Outil `magik_limbo` (hero=magik) — token « swap » sous l'identité
- Tuile LIMBO sous l'identité. **Action = le texte de la carte support Limbo** (« exhaust Limbo → swap a card in your hand with the top card of your deck ») : déposer une carte de sa main SUR le token →
  - la carte du **dessus du deck** part en **main** (`Player[color].getHandTransform()`) — c'est la carte du **slot reveal si présente, SINON le sommet de la pioche draw** (`board.mc4dbGetSlot("draw")` + `drawTopAt`) ;
  - la carte lâchée prend sa place sur le dessus du deck (slot reveal recto, ou dessus de pioche **face cachée**) ;
  - la **carte support Limbo est inclinée (exhaust, rotation 90°)** — `exhaustLimbo()`, idempotent.
- **Ne s'active QUE si la carte support Limbo est réellement en jeu** sur le tapis : `findLimboCard()` = carte **face visible**, marqueur `{limbo:true}`, près du siège, **hors main** (`getHandObjects`) et hors pioche (face cachée).
- **Marqueur `limbo` baké côté backend** (`buildTTSCard`, GMNotes `{limbo:true}`) détecté sur `real_text` (signature **`/exhaust Limbo/i`**, anglais) → **indépendant de la langue** (la carte s'appelle « Limbo » en EN mais **« Les Limbes » en FR** → un match par Nickname raterait). Card code = `45032`. GMNotes reste additif (coexiste avec `{effect}`).
- **GOTCHA `putObject`** : pour poser la carte lâchée SUR (et non SOUS) la pioche, il faut la **lever au-dessus du deck** (`setPosition dw.y+3`) PUIS `putObject` quelques frames plus tard (même méthode que `magik_reveal.hideTop`) — sinon TTS l'insère par le dessous.
- **Capture durcie** : `onObjectDrop` capture par **RAYON** (`LIMBO_HIT=1.7`, tolère le snap de la carte sur une ancre voisine, plus une boîte serrée) + **arbitrage** — ne déclenche que si le token Limbo est le tile le plus proche du drop vs `card_effects` (même rangée) → ne vole pas ses drops.
- **Exhaust respecté** : `isExhausted(card)` (rotation Y ~90/270). L'action ne se fait que si Limbo est **PRÊT**. Si **absent OU incliné** → la carte lâchée est **renvoyée en main** (`returnToHand`, **anim 2 temps** lever+pause 0.6 s+retour ; **préserve le GMNotes** contrairement à `toHand`) + **message localisé**.
- **i18n des outils** : le backend injecte **`lang`** dans le `LuaScriptState` de **tous** les outils (`resolveTools`, plus seulement quand `_assets`). ⚠ `lang` arrive en **MAJUSCULE** (« FR ») → normaliser en Lua (`string.lower():sub(1,2)`). Table `STR.fr/en` + `tr()/say()` dans l'outil.

**Ancres des outils sous l'identité (positions mémorisées, à réutiliser / affiner en jeu).** Les tuiles Magik/effets s'auto-placent en **`identity.pos + (dx, -below)`** puis **snap** sur l'ancre de statut la plus proche (`nearestSnapWorld`, rayon ~2.6) ; les slots de statut sous l'identité **ont des snap points** (contrairement aux cases des piles §3.4). Tailles via `sizeToWorld()` (cible `{1.75,1.25}` monde = carte de statut). Constantes par outil :

| Outil | dx (X) | below (-Z) | Emplacement |
|---|---|---|---|
| `card_effects` (EFFECTS) | `FX_DX = -1.3` | `FX_BELOW = 3.6` | slot **gauche** sous l'identité |
| `magik_limbo` (LIMBO) | `LIMBO_DX = +1.3` | `LIMBO_BELOW = 3.6` | slot **à droite d'EFFECTS**, même rangée (miroir) |

(Identité elle-même : backend `dx=-10.63, dz=+2.32`, cf. §3.4. `magik_reveal` : pas d'ancre identité — se **dock** à `DOCK_MULT=2.35 × slotPitch` à droite de la case draw du plateau.)

### Rotations (GOTCHA récurrent)
- **`[0,180,0]` = RECTO (FaceURL) visible ; `[0,180,180]` = dos MC (BackURL)**. Deck posé **face cachée** `[0,180,180]`. Défausse **face avant** `[0,180,0]`. Reveal recto `[0,180,0]`. (L'identité : `[0,180,180]` montre l'alter-ego `BackURL` → `is_face_down=true`.)

### Pièges appris (cette phase)
- **`dz` hors tapis = zone de MAIN** : `dz≈-7` (« sous LIMBO ») aspire les objets dans la main. Garder `dz>=-3.2` (rangée LIMBO, ON-MAT).
- **Ne PAS détruire par nom seul** : le sac de jetons du joueur s'appelle « Tough » (le sac vilain « Tough Status ») → toujours exclure les conteneurs. `card_effects.isOurItem`/`removeToughNear` durcis (exclusion Infinite/Bag/Deck).
- **Hotkeys `addHotkey` = footgun** : bindées sur `Mouse0` (clic) par erreur → détruisent au grab. (Cause du « bug Tough » : `MC4DB Remove Tough` sur Mouse0.)
- **Rapporteur de position** (dev) : `onObjectDrop` GLOBAL dans l'importeur (`IS_DEV`) → imprime `[MC4DB pos] <nom> @ <siège> : dx/dz/scale` pour régler les placements.
- **Réglages restants** : `DOCK_MULT`/scale de `magik_reveal`, tolérances (`DISCARD_HIT_*`, `DRAW_HIT_*`, `CLEAN_*`), `LIMBO_HIT`.

---

## 4quater. Effets/toolbox/panneau (session 2026-08-02) — GOTCHAS DURABLES

- **Piles à 1 carte écrasent le marqueur d'effet** : `buildTTSPile` renvoie un `CardCustom` NU pour une pile à 1 carte ; assigner `ttsObject.GMNotes = placement(...)` **efface** le `{effect}`/`{limbo}` baké par `buildTTSCard`. Symptôme : une carte à effet SEULE dans une pile (deck annexe, permanents…) perd son Tough/dégâts. **FIX = `applyPilePlacement(obj, tag, …)`** (fusionne placement + effect/limbo pour un `CardCustom`). Utiliser ce helper pour TOUTE assignation de placement de pile.
- **`obj.type` d'une Custom_Tile = `"Tile"` en runtime** (PAS `"Custom_Tile"`, qui est le champ `Name` du JSON). Ne jamais filtrer un compteur/statut par `o.type == "Custom_Tile"` (toujours faux) → filtrer par **NOM** (`o.getName() == "Counter"`/`"Tough"`…) + tag GMNotes.
- **Un outil qui est une tuile hôte doit être `BlockSquare`, PAS `Custom_Tile`** : un `Custom_Tile` **sans `CustomImage`** fait ouvrir le dialogue « CUSTOM TILE » de TTS au spawn. `BlockSquare` = bloc plat coloré, aplati via `sizeToWorld` (`setScale{sx, THICK, sz}`), sans image.
- **Effets de cartes** : `cardEffect` bake dans le GMNotes de chaque carte — `tough` (keyword Toughness), `counter=N` (Uses (N)), `charge=N` (« Enters play with N … counters », MÊME jeton/anchor que uses, clé séparée), `damage` (allié à PV → jeton dédié bas-gauche), `hp=N` (« You get/Your identity gets +N hit points », PV max — application via panneau, pas auto). `card_effects` applique via `onObjectDrop` (carte jouée DEPUIS LA MAIN, zone `SEAT_RADIUS` X / `SEAT_RADIUS_Z=22` Z) ; chaque branche gardée par `cfgEnabled(color,key)` (config panneau dans `Global`, défaut ON). `uses` à 0 → défausse (`watchUses` poll, jeton tagué `role="uses"`).
- **TOOLBOX au onLoad** : le sac « Outils » n'est PLUS dans la réponse deck → endpoint dédié **`GET /api/public/tts/toolbox`** ; l'importeur le spawn UNE fois à `onLoad` (dédup tag GMNotes `mc4db_toolbox`), **verrouillé** (Locked, pas de chute). Carton = `toolbox/_container.json` (`Custom_Model_Bag`), contient des `Infinite_Bag` de statuts colorés (Tough/Stunned/Confused) via `resolveToolbox` + `_wrap`(inline asset)/`_wrapScale`(préscale). ⚠ re-paste `SHIELD deck script.lua` requis (moteur onLoad).
- **⚠ SÉCURITÉ — ne JAMAIS réutiliser le `LuaScript` d'un objet TTS externe** fourni par l'utilisateur : un « sac » fourni contenait un script **auto-réplicant** (s'injecte dans tous les objets + `WebRequest.get("obje.glitch.me")` = RCE). Réutiliser **le mesh seul**, `LuaScript` vidé.
- **`UI.setXml` est DIFFÉRÉ (GOTCHA)** : après `self.UI.setXml(xml)`, les éléments ne sont PAS interrogeables la même frame → un `self.UI.setAttribute(id, …)` immédiat lève `Object reference not set` (l'id n'existe pas encore). C'était le bug du `togglePanel` du `control_panel`. **Patron correct** : au 1er affichage, **baker** l'état (`active="…"`) DANS le XML construit et ne faire QUE `setXml` ; n'utiliser `setAttribute` qu'ensuite (UI déjà construite, `uiBuilt` vrai). ⚠ toute variable lue par `buildXml` (ex. `panelShown`) doit être déclarée AVANT `buildXml` (portée `local` Lua). Cf. §3.7. **Note** : le panneau XML a été ABANDONNÉ au profit d'un plateau à boutons 3D (cf. « Panneau joueur » ci-dessous) — l'XML d'objet reste trop capricieux.

### Panneau joueur (`player_panel`) — plateau 2 cases, DECK-INDÉPENDANT (session 2026-08-02)
Refonte du `control_panel` XML (supprimé). **Plateau plat `Custom_Tile`** (image placeholder, localisable `player_panel.<lang>.webp`) avec **boutons 3D** (l'XML d'objet est abandonné, trop capricieux). Vit dans le **carton** (`trigger_type='toolbox'`, `bundles/TTS/tools/toolbox/player_panel.json`) → spawné au on-load, **posé À LA MAIN** (donc **pas** de `mc4dbSetSeat` par le placement). Source de vérité du Lua = un **builder Node** dans le scratchpad qui régénère le JSON (Lua embarqué) ; valider `luac -p` sur le Lua extrait.
- **Deck-indépendant** : au `onDrop`/poll, détecte son tapis (matLocation le plus proche, `SEAT_MATCH_R=22`), puis lit les **formes du héros** dans le GMNotes de la carte **Identité** (`forms:[{side,name,health,handSize}]`, baké par `buildHeroIdentityCard`, **préservé par `applyPilePlacement`**). Aucune injection depuis la réponse deck.
- **Layout (vertical)** : **nameplate au-dessus** du plateau (carré couleur du siège + `Player[color].steam_name` + **nom de la forme active**, en badges car hors plateau) ; sur le plateau : **End of turn** en haut, puis (poussé en BAS, milieu libre pour de futurs boutons) **Max HP / Hero hand / Alter-ego hand / Temporary / Hand: <total>**. Libellés = fond de la **couleur EXACTE du plateau** (image plate `#0d1016`) → « texte plat sans badge » (un bouton alpha 0 n'est PAS rendu) ; seul le nom du joueur a un vrai badge.
- **Forme active** (`activeForm()`) : **la FACE d'abord** (`is_face_down` → alter_ego/hero, comme Magik), désambiguïsation par nom UNIQUEMENT entre formes du **même côté** (⚠ héros et alter-ego peuvent partager le nom, ex. Rocket Raccoon → ne jamais matcher par nom en premier). Le total **Hand** = base forme active + `sideMod(côté)` + `Temporary`, recalculé au flip.
- **Modificateurs de main** : par CÔTÉ (`cfg.bonus.hero/.alter_ego`) + **Temporary** (`cfg.temp`, s'ajoute à toute forme). La valeur affichée = `sideMod(côté)` = manuel + **auto** (cartes `effect.handmod` sur le tapis, côté ou `both`) + **extra token** ; ±1 ajuste la part manuelle.
- **Effets auto (cartes POSÉES sur le tapis)** : `matHandmodSum(side)` (side-aware via `effect.handmod_side` = hero/alter_ego/both) et `matHpSum()` (`effect.hp`). ⚠ **Exclure les cartes EN MAIN** (`getHandObjects` → set de GUID) et la **case discard** ; ne compter que `type=="Card"` face visible. Re-scan chaque tick → **auto-réduction** au retrait/défausse.
- **PV en DELTA** : `matHpSum()` → compteur du siège via `bumpCounter` (baseline au 1er passage pour ne pas double-compter) ; **Max HP manuel (±) AUSSI en delta** (plus de réécriture absolue) → **dégâts préservés**. Max HP **affiché** = base + manuel + `matHpSum()`.
- **End of turn** = **redresse** (`readySeatCards()`, cf. gotcha) + **pioche** jusqu'au total de la forme active (reveal-aware Magik). (« Défausser la main » retirée pour l'instant, `actDiscardHand` conservée.)
- **Poll** `Wait.time(0.5,-1)` avec **signature de changement** → rebuild seulement si ça change (réactif au flip, sans scintiller ; les clics rebuild direct) + `syncAutoHp()`.
- **Présence** : `Global.mc4db_panel_<couleur>` (GUID), nettoyée à `onDestroy`. **Image toolbox** : `resolveToolbox` applique la colonne `image` via `applyToolImage` (helper partagé → `Custom_Tile` + `?v=<mtime>`) + injecte `lang/debug/built`.

**GOTCHAS durables :**
- **`getObjectFromGUID` = nil pour une carte EN MAIN** → déplacer la **réf. d'objet** de `getHandObjects`, jamais via GUID (`mc4dbDiscard{guid}` sortait en early-return `ok=true` sans rien faire ; idem exclusion du comptage).
- **Bouton TTS à alpha 0 NON rendu** (label compris) → lui donner le fond du plateau pour un « texte plat ».
- **Ready Cards** : `readyCards(mat_obj)` (mod Hitch) est porté par un **objet « Playmat Handler » CENTRAL unique** (~100 u du siège → introuvable par proximité) et fait un `Physics.cast` à `mat_obj.getPosition()` → lui passer le handler cast au centre de la table (rien). **Solution : refaire le cast localement** ancré siège — `Physics.cast({origin={SEAT.x,1,SEAT.z+4}, type=3, size={26.5,1,15.5}})` → `setRotationSmooth({0,180,z})` sur les `tag=="Card"`. Boîte < espacement sièges (28.8) → pas de bavure.

### Token Iron Man « Tech » (`iron_man_tech`, hero=`iron_man`)
Outil hero (comme Magik) : `Custom_Tile` image « Tech », **compte les cartes trait Tech** posées sur le tapis (marqueur `effect.tech` baké par `cardEffect` via `card.real_traits`, regex `\bTech\.` → indépendant langue, « Technique. » exclu ; `real_traits` ajouté aux selects deck/pack/scénario), **+1/Tech plafonné +6**, publie dans **`Global.mc4db_xhand_<couleur>_hero`** (lu par le panneau `tokenExtra("hero")`). **Taille** via `sizeToWorld` (cible 1.75×1.25 = Limbo ; un Custom_Tile brut est trop gros → toujours redimensionner). **Verrouillé par script** `self.setLock(true)` en différé (le `Locked` JSON ne suffit pas, le placement respawn déverrouillé). Bouton « Tech +N » : `rotation {0,0,0}` (un `{0,180,0}` l'inversait vs l'image).

## 5. Pièges & rappels

- **⚠ RÈGLE DURABLE — ÉVÉNEMENTIEL, PAS DE POLL** : ne JAMAIS recalculer/scanner par frame (`Wait.time(fn, POLL, -1)` + `getAllObjects()` = **saccades** × sièges). Tout nouvel outil/comportement se déclenche sur **TRIGGERS TTS** : `onObjectDrop`, `onObjectPickUp`, `onObjectDestroy`, `onObjectStateChange` (formes), `onObjectRotate` (flip/exhaust). **Patron** : `requestX()` (débounce ~0.3 s : une rafale d'événements = 1 recalcul) + **filet LENT** `Wait.time(doWork, 3–4 s, -1)` (rattrape les événements manqués / valeurs Global externes). Les seuls polls tolérés = **légers** (`getHandObjects` de la main du siège, JAMAIS `getAllObjects`). Une carte (CardCustom) n'a pas de script → c'est l'événement GLOBAL qui la livre, et son **GMNotes** (baké backend) EST la définition du trigger ; l'outil route via `obj.call(fn, params)`. **GOTCHA portée** : un handler global (`onObjectDrop`…) qui appelle une `local function` définie APRÈS lui touche le GLOBAL `nil` → **forward-déclarer** (`local requestRecompute`/`local rebuild` en tête).
- Le format TTS est **strict** : `Name` doit être `CardCustom`/`DeckCustom`/`Card`, `CustomDeck` indexé par string de `deckId`, `Transform` complet. Toute pile cassée = objet non instancié côté TTS.
- `nextDeckId` doit rester **globalement unique** dans une réponse (sinon collisions de `CustomDeck`). Toujours propager la valeur de retour des helpers.
- Ne pas mettre d'URL image directe : casse le fallback langue et le cache-busting.
- **Script deck** : le placement est **backend-driven** (`GMNotes` = JSON via `placement()`), le Lua est un moteur générique → **ajouter/déplacer une pile = backend seul**. Ne pas recoder de positions dans le Lua.
- **Rétro-compat API (CRITIQUE)** : tous les importeurs déployés ne sont pas mis à jour. Le changement de format est **purement additif** (ajout du champ `GMNotes`). L'ancien script **écrase** `GMNotes` (il le recalcule depuis le nom de pile) et lit une structure **inchangée** → il continue de tourner (placement Unique/Unique2 d'origine), sans planter. **Règle d'or : rester additif** — ne JAMAIS renommer une clé de pile (`hero`/`mainDeck`/`nemesis`/`permanents`/`sideDeck`/`specialDecks`/`linkedDecks`) ni retirer un champ existant, sinon on casse les importeurs non à jour.
- **Scripts pack & scénario** : encore à l'ancienne (le Lua calcule `GMNotes` depuis le nom de pile) — un renommage de pile doit y être répercuté à la main. À migrer vers le descripteur backend si besoin.
- `GMNotes` transporte du JSON custom (canal TTS prévu pour ça) ; le Lua fait `JSON.decode(obj.gm_notes)`.
- Cerebro : ne pas toucher, ne pas s'en inspirer.
