# TCG — Effets structurés des cartes (Guidoune Masters)

Doc de référence des paramètres JSON pour les effets de cartes. À alimenter au
fur et à mesure de l'ajout de nouvelles primitives / triggers / filtres.

Chaque effet a la forme :

```jsonc
{
  "trigger": "on_use",       // optionnel pour sorts (défaut on_use), OBLIGATOIRE pour passifs
  "action":  "damage_targets",
  "params":  { ... }         // spécifique à l'action
}
```

Le champ `effects` est un tableau d'effets, attaché soit :
- sur un **sort** (`skills[].effects`) — activé quand la carte est utilisée
- sur un **passif** (`passives[].effects`) — activé selon son `trigger`
- sur une **carte Sort/Botte/Équipement** — édité au niveau carte (`singleEffects`
  côté form), stocké en interne dans `skills[0].effects`

---

## Triggers

| Trigger        | Quand ça déclenche                                                          | Source de contexte                            |
| -------------- | --------------------------------------------------------------------------- | --------------------------------------------- |
| `on_use`       | Utilisation d'un sort (skill_attack) ou invocation d'une carte Sort         | `attackerSlot`, `defenderSlot` optionnel      |
| `on_summon`    | Placement en zone monster/hero                                              | `attackerSlot` = slot cible                   |
| `on_turn_end`  | Fin de tour, pour chaque monstre/héros du joueur actif                      | `attackerSlot` = position du combattant       |
| `on_engage`    | Une carte énergie vient d'être engagée                                      | `sourceZone=energy`, `sourceIndex`            |
| `on_activate`  | Une botte secrète est activée en réaction à une action adverse              | `attackerPlayerId` = propriétaire de la trap, `sourceAttackerPid` = joueur qui a déclenché |
| `on_opponent_draw` | L'adversaire vient de piocher (passif sur monstre/héros en jeu)         | `attackerPlayerId` = propriétaire du passif, `sourceAttackerPid` = drawer                  |

Query-only (jamais fired, lus par le moteur au moment d'une action) :
- `reduce_incoming_damage` — lu par `TcgEngineService::mitigateDamage`
- `modify_skill_cost`      — lu par `TcgEngineService::getEffectiveSkillCost`
- `require_zone_all_of`    — lu par `TcgEngineService::canEngage`
- `modify_shield_break_count` — lu par `TcgEngineService::getShieldBreakCount`

À implémenter (Phase 2) : `on_shield_break`, `on_opponent_draw`, `on_opponent_summon` (déclencheurs adverses).

---

## Condition périodique

Un effet peut être conditionné via `condition.every_n_turns` (+ `condition.turn_offset` optionnel). L'effet ne fire que lorsque `(gameState.turn - offset) % N === 0`.

```jsonc
{
  "trigger":   "on_turn_end",
  "condition": { "every_n_turns": 3 },
  "action":    "restore_eg_targets",
  "params":    { "filter": { "target": "self" }, "amount": 9 }
}
```

Cet exemple couvre la partie EG de **Heure de l'appel (Benoixsme)** — la partie « ne peut pas agir » nécessite un `apply_status_targets no_attack` conditionné similairement.

### `if_var` — condition sur une variable du contexte

```jsonc
{ "condition": { "if_var": "activate", "equals": true }, "action": "reveal_last_drawn", "params": {} }
```

- `if_var` : nom d'une var à lire dans `context.vars`. Si `equals` est fourni, égalité stricte ; sinon truthy check.
- Utilisé pour chaîner des effets conditionnels au sortir d'un `prompt_confirm` en **structure plate** — sans passer par un `branch`. C'est indispensable quand les effets conditionnels contiennent eux-mêmes des prompts (car `sequence`/`branch` ne propagent pas la suspension à leurs enfants).

Pattern typique (Vigilance extrême) :
```jsonc
[
  { "trigger": "on_opponent_draw", "action": "prompt_confirm", "params": { "key": "act", "label": "Activer ?" } },
  { "trigger": "on_opponent_draw", "condition": { "if_var": "act" }, "action": "prompt_target", "params": { "key": "ze", "filter": {...} } },
  { "trigger": "on_opponent_draw", "condition": { "if_var": "act" }, "action": "engage_energy_slot", "params": { "index": "@ze" } }
]
```

---

## Contexte scoped & références `@key`

Chaque `applyEffects()` maintient un contexte partagé entre les effets d'une même exécution, exposé sous `context.vars`. Les primitives de calcul (`random_int`, `compare`, `count_targets`) et les prompts (`prompt_choice`) écrivent dans `vars[key]`. Les effets suivants peuvent lire ces valeurs via des références **`"@key"`** dans n'importe quel param.

Exemple : le dé stocke sa valeur dans `vars.rolled`, une comparaison la relit :

```jsonc
[
  { "action": "random_int", "params": { "key": "rolled", "min": 1, "max": 6 } },
  { "action": "compare",    "params": { "key": "matched", "op": "eq", "a": "@rolled", "b": 6 } }
]
```

Les listes d'effets imbriquées (`then`/`else` d'un `branch`, `effects` d'un `sequence`) NE sont PAS résolues à l'avance — leurs `@ref` internes sont évalués à leur propre tour d'exécution, ce qui garantit qu'un enfant lit la version la plus récente d'une variable modifiée par un frère précédent.

---

## Suspension et reprise (`prompt_*`)

Certains effets requièrent un input joueur mid-résolution (déclarer un nombre, choisir une cible, valider une réaction). Ces effets **suspendent** le moteur :

1. La primitive retourne `{suspend: true, prompt: {...}, key: 'declared'}`
2. `applyEffects` sauvegarde `gameState.pendingEffect = {trigger, effects, nextIndex, context, prompt, suspendedFor}` et rend la main
3. Le controller expose `pendingPrompt` dans la réponse AJAX
4. Le client affiche le modal correspondant, POST `action=resume_effect&value=<réponse>`
5. Côté serveur, `TcgEngineService::resumeEffect(state, userValue)` injecte la valeur dans `context.vars[suspendedFor]` et continue depuis `nextIndex`
6. Une nouvelle suspension peut intervenir → re-boucle

**Guard important** : tant que `pendingEffect` n'est pas null dans le gameState, le controller REFUSE toute autre action que `resume_effect`. C'est ce qui empêche le joueur de contourner un choix.

---

## Animations en attente (`pendingAnimations`)

Certaines primitives déclenchent une animation côté client — pour l'instant seul `random_int` empile un événement `{type: 'dice', value: N}` dans `gameState.pendingAnimations`. Le controller draine cette liste dans chaque réponse AJAX (champ `pendingAnimations`), le JS l'exécute avant d'ouvrir un éventuel prompt.

Pour désactiver l'animation d'un `random_int` (ex. tirage silencieux), passer `animate: false` dans les params. Par défaut, elle est active si `min=1 && max=6`.

---

## Filter DSL

Utilisé par toutes les actions qui ciblent des cartes sur le terrain.

```jsonc
{
  "target":               "self" | "primary",  // raccourci (voir plus bas)
  "side":                 "own" | "opponent",  // relatif au caster (défaut "own")
  "zone":                 "monster" | "hero" | "spell" | "trap" | "any",  // défaut "monster" ; "any" = monster+hero
  "elementalType":        6,                    // filtre par type élémental (id d'entité Element)
  "excludeSelf":          true,                 // exclut la carte source
  "excludePrimaryTarget": true                  // exclut la cible primaire du sort
}
```

Résolution :
1. `target: "self"` → carte source (via `attackerPlayerId` + `attackerSlot`, ou `sourceZone=energy` si on_engage).
2. `target: "primary"` → cible primaire (`defenderPlayerId` + `defenderSlot`).
3. Sinon → itère la/les `zone` du camp désigné par `side`, filtre par `elementalType` + exclusions.

---

## Catalogue des primitives

### `damage_targets` — dégâts sur zone

```jsonc
{
  "action": "damage_targets",
  "params": {
    "filter": { "side": "opponent", "zone": "monster", "excludePrimaryTarget": true },
    "amount": 10
  }
}
```

**Exemple — Torpillage ! (Valboche)** : « Tous les autres monstres de l'adversaire subissent 10 dégâts »

```jsonc
[{
  "action": "damage_targets",
  "params": { "filter": { "side": "opponent", "zone": "monster", "excludePrimaryTarget": true }, "amount": 10 }
}]
```

---

### `heal_targets` — soins ciblés

```jsonc
{
  "action": "heal_targets",
  "params": {
    "filter": { "side": "own", "zone": "monster", "elementalType": 1 },
    "amount": 30
  }
}
```

Le PV soigné est cap à `pvMax` de la carte cible.

**Exemple — Maintenance mécanique (Valboche)** : « Tous vos monstres Acier récupèrent 30 PV »

```jsonc
[{ "action": "heal_targets", "params": { "filter": { "side": "own", "zone": "monster", "elementalType": 1 }, "amount": 30 } }]
```

**Exemple — Alamdoulillah (Test Man, passif)** : « Regagne 30 PV à la fin de chaque tour »

```jsonc
[{ "trigger": "on_turn_end", "action": "heal_targets", "params": { "filter": { "target": "self" }, "amount": 30 } }]
```

---

### `self_damage` — auto-dégâts sur la source

```jsonc
{ "action": "self_damage", "params": { "amount": 10 } }
```

**Exemple — Atterrissage forcé (Matt)** : partie auto-dégâts.

---

### `restore_eg_targets` — restaure l'EG

```jsonc
{
  "action": "restore_eg_targets",
  "params": { "filter": { "side": "own", "zone": "monster" }, "amount": "max" }
}
```

`amount` peut être un entier ou la chaîne `"max"` (restaure à `egMax`).

**Exemple — J'ai très intelligent (Gustave, passif on_summon)** : « L'EG de tous vos monstres est restaurée lorsque cette carte est invoquée »

```jsonc
[{ "trigger": "on_summon", "action": "restore_eg_targets", "params": { "filter": { "side": "own", "zone": "monster" }, "amount": "max" } }]
```

---

### `modify_stat_targets` — modifie une stat (permanent)

```jsonc
{
  "action": "modify_stat_targets",
  "params": {
    "filter":   { "side": "own", "zone": "monster" },
    "stat":     "defense",   // attaque | defense | arcane | sagesse | pvMax | egMax
    "delta":    5,
    "duration": "permanent"  // MVP : seule valeur supportée
  }
}
```

Cumulatif via `slot.statMods[stat]` — les buffs s'empilent tant que la carte reste en jeu.

**Exemple — Consolidation (Jack)** : « Augmente la Défense de tous vos monstres de 5 »

---

### `set_stat_targets` — fixe / delta relatif sur currentPv ou currentEg

Deux modes :
- **Absolu** (`value`) : fixe la stat à cette valeur exacte (clamp min 0).
- **Delta** (`delta`) : ajoute/retire une quantité (clamp à `[0, max]` via la carte).

```jsonc
// Mode absolu
{ "action": "set_stat_targets", "params": { "filter": { "target": "primary" }, "stat": "currentEg", "value": 0 } }

// Mode delta
{ "action": "set_stat_targets", "params": { "filter": { "target": "primary" }, "stat": "currentEg", "delta": -1 } }
```

`stat` doit être `currentPv` ou `currentEg`. Un `currentPv` retombant à 0 déclenche la destruction propre via `applyDamageToSlot`.

**Exemple — Rayon Ramadan (Gustave)** : « L'EG de la cible est réduit à 0 »

```jsonc
[{ "action": "set_stat_targets", "params": { "filter": { "target": "primary" }, "stat": "currentEg", "value": 0 } }]
```

---

### `add_card_to_hand` — pioche filtrée depuis deck ou cimetière

```jsonc
{
  "action": "add_card_to_hand",
  "params": {
    "source":        ["deck"],              // "deck" | "graveyard" | list | null (tout)
    "cardType":      ["spell", "equipment"],// "creature"|"spell"|"trap"|"hero"|"equipment" | list | null
    "summonCost":    null,                  // int | list | null
    "elementalType": 6                      // int | list | null
  }
}
```

Tous les critères sont optionnels (`null` = pas de contrainte). Scalar ou list acceptés (logique OR). Sélection aléatoire parmi les candidats.

**Exemple — Gatlingologie (Matt)** : « Ajoutez un sort ou équipement Feu à votre main »

```jsonc
[{ "action": "add_card_to_hand", "params": { "source": "deck", "cardType": ["spell", "equipment"], "elementalType": 6 } }]
```

**Exemple — Data Restore (D.D.)** : « Ajoutez un sort ou Botte Secrète Psychique de votre Cimetière à votre main »

```jsonc
[{ "action": "add_card_to_hand", "params": { "source": "graveyard", "cardType": ["spell", "trap"], "elementalType": 13 } }]
```

---

### `apply_status_targets` — applique un statut

```jsonc
{
  "action": "apply_status_targets",
  "params": {
    "filter": { "target": "primary" },
    "status": {
      "type":     "no_attack",   // no_attack | disable_skill
      "duration": 1,             // nombre de tours (défaut 1)
      "skillIndex": 0            // requis pour disable_skill
    }
  }
}
```

Le status est stocké dans `slot.statuses[]` avec `applied_turn` pour éviter le tick au tour de pose. Décrémenté en `endTurn`.

Types de status supportés :

| type            | Effet                                                                  | Params supplémentaires |
| --------------- | ---------------------------------------------------------------------- | ---------------------- |
| `no_attack`     | La carte ne peut pas déclarer d'attaque (normal_attack + skill_attack) | —                      |
| `disable_skill` | Un sort précis de la carte est désactivé                               | `skillIndex` (int)     |

**Exemple — Bisou de bonne nuit (Benoixsme)** : « La cible ne peut pas attaquer au prochain tour »

```jsonc
[{ "action": "apply_status_targets", "params": { "filter": { "target": "primary" }, "status": { "type": "no_attack", "duration": 1 } } }]
```

**Exemple — Atterrissage forcé (Matt, partie disable)** : le sort ne peut pas être ré-utilisé au tour suivant.

```jsonc
[{ "action": "apply_status_targets", "params": { "filter": { "target": "self" }, "status": { "type": "disable_skill", "duration": 1, "skillIndex": 2 } } }]
```

---

### `destroy_targets` — envoi au cimetière

```jsonc
{
  "action": "destroy_targets",
  "params": { "filter": { "side": "opponent", "zone": "trap" } }
}
```

Supporte zones `monster`, `hero`, `spell`, `trap`. Les monstres/héros passent par `applyDamageToSlot` (destruction propre — trigger éventuels de mort à venir).

---

### `return_to_hand_targets` — retour en main

```jsonc
{
  "action": "return_to_hand_targets",
  "params": { "filter": { "target": "self" } }
}
```

Supporte zones `monster`, `hero`, `energy`. Utile pour effets de rebond.

**Exemple — Tactique de ouf malade (Gustave, passif on_engage)** : « Lorsque cette carte est engagée, elle revient immédiatement dans votre main »

```jsonc
[{ "trigger": "on_engage", "action": "return_to_hand_targets", "params": { "filter": { "target": "self" } } }]
```

Le contexte on_engage passe `sourceZone=energy` + `sourceIndex`, et `target: self` résout vers la carte énergie source.

---

### `modify_shield_break_count` — passif query-only

```jsonc
{ "action": "modify_shield_break_count", "params": { "count": 2 } }
```

**Ne se déclenche pas via un trigger** — c'est un passif « lu » par `TcgEngineService::getShieldBreakCount()` lors d'une attaque victorieuse. Pas besoin de `trigger` mais toléré (no-op silencieux).

**Exemple — Double briseur (Valboche)** : « Les attaques de ce monstre brisent deux boucliers au lieu d'un seul »

```jsonc
[{ "action": "modify_shield_break_count", "params": { "count": 2 } }]
```

Triple briseur → `"count": 3`.

---

### `summon_from_zone` — invocation programmée

```jsonc
{
  "action": "summon_from_zone",
  "params": {
    "source":             "deck",        // "hand" | "deck" | "graveyard"
    "targetZone":         "monster",     // "monster" | "hero"
    "slot":               -1,            // -1 = premier libre (défaut)
    "cardType":           "hero",        // filtre, scalaire ou liste
    "elementalType":      null,          // filtre, scalaire ou liste (surchargé par matchSourceElement)
    "matchSourceElement": true,          // filtre : au moins 1 type en commun avec la carte source
    "ignoreSummonCost":   true,          // paie 0 quel que soit le coût
    "energyType":         0              // type utilisé pour payer si non-ignoré (0 = universel)
  }
}
```

Sélection aléatoire parmi les candidats matchant les filtres. Déclenche les effets `on_summon` du passif normalement.

**Exemple — Montée en grade (Benoixsme)** : « Envoyez un monstre au Cimetière, puis invoquez un Héros avec au moins un type en commun, en ignorant son coût d'invocation »

```jsonc
[
  { "action": "destroy_targets", "params": { "filter": { "target": "self" } } },
  { "action": "summon_from_zone", "params": {
      "source": "hand", "targetZone": "hero", "cardType": "hero",
      "matchSourceElement": true, "ignoreSummonCost": true
  }}
]
```

---

### `apply_player_status` — statut au niveau JOUEUR

```jsonc
{
  "action": "apply_player_status",
  "params": {
    "filter": { "side": "opponent" },     // "own" (défaut) ou "opponent" — cible le joueur
    "status": { "type": "disable_traps", "duration": 1 }
  }
}
```

Le statut est stocké dans `players[pid].statuses[]` avec `applied_turn`. Décrémenté par `endTurn` comme les statuts de slot.

Types supportés (lus par le moteur quand la restriction s'applique) :

| type              | Effet                                                     |
| ----------------- | --------------------------------------------------------- |
| `disable_traps`   | Le joueur ne peut pas activer de bottes secrètes          |
| `disable_spells`  | Le joueur ne peut pas placer/activer de sorts             |
| `no_summon`       | Le joueur ne peut pas invoquer                            |
| `no_draw`         | Le joueur ne pioche pas au prochain début de tour         |

**Exemple — Logorhée (Gustave)** : « Votre adversaire ne peut pas activer de Bottes Secrètes pendant ce tour »

```jsonc
[{ "action": "apply_player_status", "params": { "filter": { "side": "opponent" }, "status": { "type": "disable_traps", "duration": 1 } } }]
```

---

### `reduce_incoming_damage` — passif query-only

```jsonc
{
  "action": "reduce_incoming_damage",
  "params": {
    "filter":        { "target": "self" }, // ou { side: "own", zone: "any", excludeSelf: true }
    "damageElement": 5,                    // int (id d'entité Element) — si absent, s'applique à TOUS les éléments
    "multiplier":    0.5,                  // OU
    "set":           0                     // valeur forcée (immunité totale = set: 0)
  }
}
```

**Ne se déclenche pas via un trigger** — lu par `TcgEngineService::mitigateDamage()` chaque fois qu'un slot subit des dégâts. Les modificateurs multiplicatifs se composent ; un `set` écrase le résultat final.

**Exemple — Airbus Maximus (Matt, passif)** : « Cette carte ne reçoit aucun dégât de type Terre »

```jsonc
[{ "action": "reduce_incoming_damage", "params": { "filter": { "target": "self" }, "damageElement": 5, "set": 0 } }]
```

**Exemple — Bon air marin (Valboche, passif)** : « Tous les dégâts Eau reçus par vos autres monstres sont réduits de moitié »

```jsonc
[{ "action": "reduce_incoming_damage", "params": { "filter": { "side": "own", "zone": "any", "excludeSelf": true }, "damageElement": 4, "multiplier": 0.5 } }]
```

---

### `modify_skill_cost` — passif query-only

```jsonc
{ "action": "modify_skill_cost", "params": { "delta": -1 } }
```

**Ne se déclenche pas via un trigger** — lu par `TcgEngineService::getEffectiveSkillCost()` au moment de payer un sort. Les `delta` de tous les passifs actifs du côté caster s'ajoutent (clamp à 0).

**Exemple — Pingrerie (D.D., passif)** : « Les coûts d'utilisation des sorts sont réduits de 1 »

```jsonc
[{ "action": "modify_skill_cost", "params": { "delta": -1 } }]
```

---

### `require_zone_all_of` — passif query-only, contrainte d'engagement

```jsonc
{
  "action": "require_zone_all_of",
  "params": { "zone": "energy", "elementalType": 5 }
}
```

**Ne se déclenche pas via un trigger** — lu par `TcgEngineService::canEngage()` au moment de tenter d'engager une carte énergie. Si la zone visée contient au moins une carte qui NE porte PAS l'élément requis, l'engagement est refusé.

**Exemple — Force ouvrière (Jack, passif)** : « Cette carte ne peut être engagée dans votre ZE que si celle-ci ne contient que des cartes Terre »

```jsonc
[{ "action": "require_zone_all_of", "params": { "zone": "energy", "elementalType": 5 } }]
```

---

## Résultats retournés (log)

Chaque effet appliqué est loggé dans `state.log[]` :

```jsonc
{
  "turn":   3,
  "player": 42,
  "action": "effect",
  "effect": "damage_targets",
  "result": { "ok": true, "targets": [{ "playerId": 43, "slot": 1, "dead": false, "damage": 10 }, ...] }
}
```

En cas d'erreur (action inconnue, params insuffisants), `result.ok = false` avec un `error` explicite. L'effet n'interrompt jamais la résolution des suivants.

---

## Primitives de contrôle et de calcul (Phase 3+)

Ces primitives ne modifient PAS l'état du jeu directement — elles alimentent `context.vars` ou orchestrent d'autres effets. Elles rendent possibles les combinaisons interactives ou conditionnelles.

### `random_int` — tire un entier aléatoire

```jsonc
{ "action": "random_int", "params": { "key": "rolled", "min": 1, "max": 6, "animate": true } }
```

- `key` (obligatoire) : nom de la var à remplir
- `min` / `max` (défaut 1 / 6)
- `animate` (défaut : `true` si min=1 && max=6, sinon `false`) — empile une animation de dé

### `compare` — écrit un bool dans `vars[key]`

```jsonc
{ "action": "compare", "params": { "key": "matched", "op": "eq", "a": "@declared", "b": "@rolled" } }
```

Opérateurs : `eq`, `neq`, `lt`, `lte`, `gt`, `gte`.

### `count_targets` — écrit le nombre de cibles matchant un filter

```jsonc
{ "action": "count_targets", "params": { "key": "n", "filter": { "side": "own", "zone": "monster" } } }
```

Utile pour scaler des dégâts (`amount: "@n * 20"` — pas encore d'arithmétique, à ajouter si besoin ; pour l'instant utiliser directement `"@n"`).

### `sequence` — regroupe des effets en un bloc

```jsonc
{ "action": "sequence", "params": { "effects": [ … ] } }
```

Principalement utile à l'intérieur d'un `branch` ou d'un futur `foreach`.

### `branch` — if/else basé sur un bool dans `vars[key]`

```jsonc
{
  "action": "branch",
  "params": {
    "key":  "matched",
    "then": [ { "action": "destroy_targets", "params": { "filter": { "side": "opponent", "zone": "monster" } } } ],
    "else": [ { "action": "destroy_targets", "params": { "filter": { "target": "self" } } } ]
  }
}
```

Les listes `then` / `else` sont exécutées via la même mécanique que `applyEffects`, donc peuvent contenir tous les autres effets (y compris d'autres `branch`, prompts…).

### `prompt_choice` — demande un choix au joueur (suspend le moteur)

```jsonc
{
  "action": "prompt_choice",
  "params": {
    "key":     "declared",
    "label":   "Déclarez un nombre entre 1 et 6",
    "options": [1, 2, 3, 4, 5, 6]
  }
}
```

Retourne `{suspend: true, prompt: {...}}`. Le runner stoppe l'exécution et sauvegarde `pendingEffect`. Reprise via `resumeEffect(state, userValue)`.

### `prompt_confirm` — Oui/Non binaire (suspend)

```jsonc
{ "action": "prompt_confirm", "params": { "key": "activate", "label": "Activer ?", "yes": "Oui", "no": "Non" } }
```

Écrit un bool dans `vars[key]` (1 → true, 0 → false).

### `prompt_target` — picker de cible sur le terrain (suspend)

```jsonc
{
  "action": "prompt_target",
  "params": {
    "key":    "ze_slot",
    "label":  "Choisissez une carte ZE à engager",
    "filter": { "side": "own", "zone": "energy", "engaged": false }
  }
}
```

- Le filter est le DSL habituel + un flag `engaged: true|false` spécifique à la zone `energy`.
- Le runner pré-résout la liste des cibles valides et l'expose dans `prompt.validTargets` : `[{playerId, zone, slot}, ...]`.
- La réponse client est l'index de slot choisi (int). `-1` = annulation → écrit `-1` dans la var.

---

## Exemple d'assemblage complet : Épiphanie (Gustave)

Le sort **Épiphanie** — « Déclarez un nombre 1-6, lancez un dé. Si match → détruit tout le terrain adverse ; sinon → détruit cette carte » — est câblé ainsi :

```jsonc
[
  {
    "action": "prompt_choice",
    "params": { "key": "declared", "label": "Épiphanie : déclarez un nombre entre 1 et 6", "options": [1,2,3,4,5,6] }
  },
  {
    "action": "random_int",
    "params": { "key": "rolled", "min": 1, "max": 6 }
  },
  {
    "action": "compare",
    "params": { "key": "matched", "op": "eq", "a": "@declared", "b": "@rolled" }
  },
  {
    "action": "branch",
    "params": {
      "key":  "matched",
      "then": [
        { "action": "destroy_targets", "params": { "filter": { "side": "opponent", "zone": "monster" } } },
        { "action": "destroy_targets", "params": { "filter": { "side": "opponent", "zone": "hero"    } } },
        { "action": "destroy_targets", "params": { "filter": { "side": "opponent", "zone": "spell"   } } },
        { "action": "destroy_targets", "params": { "filter": { "side": "opponent", "zone": "trap"    } } }
      ],
      "else": [
        { "action": "destroy_targets", "params": { "filter": { "target": "self" } } }
      ]
    }
  }
]
```

Flow joueur :
1. Sélection du sort → damage de base 30 sur cible primaire (ligne géré par `resolveSkillAttack`), puis on_use fires
2. `prompt_choice` suspend → modal admin s'ouvre avec 6 boutons
3. Réponse → `random_int` tire côté serveur, empile une animation dé → widget déclenché côté client avec la valeur pré-tirée
4. `compare` écrit `matched`, `branch` sélectionne then/else, `destroy_targets` applique

---

## Effets encore non couverts

- **Vigilance extrême (D.D.)** — trigger `on_opponent_draw` (pas encore implémenté) + prompt oui/non + prompt cible d'engagement. `prompt_confirm` existe déjà, reste à ajouter le trigger `on_opponent_draw` côté moteur et une primitive `prompt_target` (sélection de slot sur le terrain).

---

## Bottes secrètes

Les cartes de type **trap** fonctionnent différemment des sorts : elles sont placées face verso, restent latentes, et s'activent en RÉACTION à une action adverse.

### Structure d'une trap

Stockée comme un wrapper single-action dans `skills[0]` (comme les sorts), avec un champ supplémentaire `activationTrigger` :

```jsonc
{
  "name":              "Négociation",
  "type":              "physical",
  "elementId":         null,
  "cost":              0,
  "damage":            0,
  "description":       "",
  "activationTrigger": "opponent_attack_declared",
  "effects": [
    { "action": "cancel_source_action", "params": { "reason": "Attaque annulée par Négociation." } },
    { "action": "add_card_to_hand",     "params": { "source": "deck", "targetPlayer": "opponent" } }
  ]
}
```

Le trigger d'activation est édité via le select **« Trigger d'activation »** dans le form admin (visible uniquement quand cardType = trap).

### Triggers supportés

| Trigger                     | Quand ça matche                                                                    | Câblé |
| --------------------------- | ---------------------------------------------------------------------------------- | ----- |
| `opponent_attack_declared`  | L'adversaire déclare une attaque (`normal_attack` ou `skill_attack`) ciblant un défenseur | ✅    |
| `opponent_direct_attack`    | L'adversaire déclare une attaque directe (bouclier — pas de `defenderSlot`)        | ✅    |
| `opponent_skill_used`       | L'adversaire utilise un sort (fired en parallèle de attack_declared/direct_attack) | ✅    |
| `opponent_summon`           | L'adversaire invoque une carte (`summon` action, avant placement)                  | ✅    |
| `opponent_draw`             | (côté passif on-field seulement, cf. Vigilance extrême)                            | ✅    |

Une trap peut déclarer n'importe lequel de ces triggers via son champ `activationTrigger`. Le controller fait un match multi-triggers pour les actions d'attaque : un `skill_attack` avec défenseur cherche à la fois `opponent_attack_declared` ET `opponent_skill_used` (une trap qui matche l'un ou l'autre est éligible).

### Flux de résolution

1. **Joueur A** poste une action (attaque) → le controller détecte le trigger via `trapTriggerForAction` et appelle `TcgEngineService::offerTrapReactions($trigger, $attackerPid, $sourceContext)`.
2. **`offerTrapReactions`** itère la trapZone de l'adversaire, retient les trap non-triggered dont `activationTrigger` matche l'un des triggers passés (`string|list<string>`), et construit une séquence **plate** d'effets avec condition `if_var` (pour permettre aux prompts imbriqués de suspendre proprement) :
   ```
   [ prompt_confirm(trigger=on_activate, key=activate_trap_N, label="Activer <Nom> ?"),
     mark_trap_activated(trigger=on_activate, condition:{if_var:activate_trap_N}, slot=N),
     ...trap.effects (chacun avec trigger=on_activate + condition:{if_var:activate_trap_N}) ]
   ```
   pour chaque candidate, puis appelle `applyEffects(..., 'on_activate')`.
3. Si un `prompt_confirm` suspend → le controller stashe l'action originale dans `state.deferredAction`, retourne la réponse avec `pendingPrompt`. Le joueur adverse voit le modal Oui/Non.
4. **Réponse Oui** → `mark_trap_activated` révèle la carte (`triggered:true`, `revealed:true`), les effets `then` s'exécutent (ce qui peut inclure `cancel_source_action`).
5. **Réponse Non** → la branche `then` est sautée, la trap reste face verso pour un futur trigger.
6. Une fois la chaîne résolue (aucun prompt en attente), le controller ré-exécute `deferredAction` — **sauf si** `cancelledSourceAction` a été set par une trap.
7. En fin de tour, `endTurn` défausse les trap `triggered:true` au cimetière.

### Primitives spécifiques trap

- **`prompt_confirm({key, label, yes?, no?})`** — variante bool de prompt_choice, retourne un bool dans `context.vars[key]`.
- **`cancel_source_action({reason?})`** — set `gameState.cancelledSourceAction`, lu par le controller après résolution.
- **`mark_trap_activated({playerId, slot})`** — injectée automatiquement par `offerTrapReactions` en tête de branche `then` ; passe la trap à `triggered:true` + `revealed:true`.

### Exemples

**Négociation (#19)** — annule l'attaque + force pioche adverse

```jsonc
"activationTrigger": "opponent_attack_declared",
"effects": [
  { "action": "cancel_source_action" },
  { "action": "add_card_to_hand", "params": { "source": "deck", "targetPlayer": "opponent" } }
]
```

**Intervention d'élite (#5)** — sur attaque directe, invoque un Guerrier depuis la main

```jsonc
"activationTrigger": "opponent_direct_attack",
"effects": [
  { "action": "summon_from_zone", "params": {
      "source":           "hand",
      "targetZone":       "monster",
      "classification":   "Guerrier",
      "ignoreSummonCost": true
  } }
]
```

### Extensions du filter DSL pour les traps

- **`add_card_to_hand`** — nouveau param `targetPlayer: "self"|"opponent"` (défaut self) + nouveau filtre `classification` (str ou liste).
- **`summon_from_zone`** — nouveau filtre `classification`.

Ces extensions marchent aussi hors contexte trap (utilisables dans n'importe quel sort/passif).

---

## Passifs réactifs sur le terrain (`on_opponent_draw` & co)

Les passifs d'un monstre/héros peuvent réagir à une action adverse — équivalent des trap mais depuis un slot monster/hero au lieu du trap zone. Implémenté aujourd'hui : `on_opponent_draw`. Extensible avec `on_opponent_summon`, `on_opponent_skill_used`, etc.

### Point d'accroche moteur

`TcgEngineService::offerFieldReactions($state, $trigger, $attackerPid)` :
- Itère les monstres + héros de l'adversaire de `$attackerPid`
- Pour chaque passif dont un effet a le bon trigger, construit un wrap **plat** :
  ```
  [ prompt_confirm(key=reactive_S_P, label="Activer <Nom> ?"),
    ...matched effects rewritten with condition: {if_var: key}, trigger: <trigger> ]
  ```
- Injecte `context.attackerPlayerId` = propriétaire du passif, `context.sourceAttackerPid` = joueur qui a triggeré.

Ancré actuellement dans `startTurn` : après une pioche réussie, fire `on_opponent_draw`. Le contrôleur détecte la suspension et sauvegarde comme pour les traps.

### Primitives associées

- **`engage_energy_slot({playerId?, index})`** — engage une carte de la ZE (silencieux si invalide/déjà engagée). Coût réactif classique.
- **`reveal_last_drawn({playerId?})`** — empile une animation `reveal_card` dans `pendingAnimations`. Le client affiche un modal transitoire avec l'illustration + nom. `playerId` défaut = `context.sourceAttackerPid`.

### Exemple — Vigilance extrême (D.D. #3)

```jsonc
{
  "name": "Vigilance extrême",
  "effects": [
    {
      "trigger": "on_opponent_draw",
      "action":  "prompt_target",
      "params":  {
        "key":    "ve_ze",
        "label":  "Vigilance extrême — sélectionnez une carte ZE à engager",
        "filter": { "side": "own", "zone": "energy", "engaged": false }
      }
    },
    {
      "trigger": "on_opponent_draw",
      "action":  "engage_energy_slot",
      "params":  { "index": "@ve_ze" }
    },
    {
      "trigger": "on_opponent_draw",
      "action":  "reveal_last_drawn",
      "params":  {}
    }
  ]
}
```

Note : le `prompt_confirm` d'activation est injecté automatiquement par `offerFieldReactions` — le passif n'a qu'à décrire les effets post-activation. La condition `if_var` est ajoutée par le moteur sur chaque effet du passif.

---

## Notes d'implémentation

- **Contexte** : `attackerPlayerId`, `attackerSlot`, `defenderPlayerId?`, `defenderSlot?`, `sourceCardId`, `sourceZone?`, `sourceIndex?`. Toutes les primitives lisent depuis ce contexte — un effet n'a pas besoin de connaître qui l'a joué explicitement.
- **Fichiers pointeurs** :
  - Runner : [src/Service/TcgEffectRunner.php](../src/Service/TcgEffectRunner.php)
  - Points d'injection triggers : `resolveSkillAttack`, `summonCard`, `endTurn`, `engageEnergy` dans [src/Service/TcgEngineService.php](../src/Service/TcgEngineService.php)
  - Éditeur : [templates/admin/trading_card/form.html.twig](../templates/admin/trading_card/form.html.twig) (macros `effectsEditor` + JS `TEMPLATES`)
- **Ajouter une primitive active** :
  1. Nouveau case dans le `match ($action)` de `TcgEffectRunner::apply()`
  2. Méthode privée `xxxTargets()`
  3. Ligne dans `TEMPLATES` (JS) + `<option>` dans le select des macros du form
  4. Section dans ce doc avec au moins un exemple réel
- **Ajouter un passif query-only** :
  1. Case no-op dans le `match ($action)` (comme `modify_shield_break_count`)
  2. Nouvelle méthode publique sur `TcgEngineService` qui itère les passifs concernés
  3. Point d'appel dans le flux moteur (attaque / cost / engage / draw / …)
  4. Ligne dans `TEMPLATES` + option dans le select + section doc
