# Algorithmes de calcul — Obsidienne

Ce document décrit tous les algorithmes utilisés dans l'application, expliqués simplement.

---

## 1. Système de tiers des jauges

Chaque jauge (baffeur, caresseur, foudroyeur, abreuvoir, dragofesse, mangeoire) a un **niveau** entre 0 et 100 000.
Ce niveau détermine un **tier** qui fixe la vitesse de conversion :

| Niveau de jauge | Tier | Taux (pts / 10 sec) |
|-----------------|------|---------------------|
| 0 – 40 000      | 1    | 10                  |
| 40 001 – 70 000  | 2    | 20                  |
| 70 001 – 90 000  | 3    | 30                  |
| 90 001 – 100 000 | 4    | 40                  |

**Principe** : La jauge se **vide** en donnant des points à la DD. Plus la jauge est haute, plus elle se vide vite (tier élevé = plus de points par tick).

Un "tick" = 10 secondes de timer.

---

## 2. Calcul des points gagnés dans le temps — `gainedIn(lvl, sec)`

**Question** : "Si ma jauge démarre au niveau `lvl` et tourne pendant `sec` secondes, combien de points ma DD gagne-t-elle ?"

**Algorithme** :
1. On part du haut (tier 4) et on descend
2. Pour chaque palier, on calcule combien de ticks on peut consommer avant de passer au palier en-dessous
3. On additionne les points de chaque palier traversé

**Exemple** : Jauge à 95 000, timer 60 sec (= 6 ticks)
- Tier 4 (90k–100k) : 5 000 pts dispo ÷ 40/tick = 125 ticks possibles → on en utilise 6 → gain = 6 × 40 = **240 pts**
- Jauge restante : 95 000 - 240 = 94 760

La jauge descend au fur et à mesure qu'elle donne des points. Quand elle passe sous un seuil de tier, le taux ralentit.

---

## 3. Temps pour gagner X points — `timeToGain(lvl, pts)`

**Question** : "Combien de temps faut-il pour gagner `pts` points depuis une jauge au niveau `lvl` ?"

C'est l'inverse de `gainedIn`. On parcourt les paliers du haut vers le bas :
1. Pour chaque tier, combien de points peut-on donner avant de descendre au palier suivant ?
2. On prend le minimum entre les points disponibles et les points restants à donner
3. On calcule le temps correspondant : `ceil(points / taux) × 10 sec`

Si la jauge se vide complètement avant d'atteindre l'objectif → retourne `Infinity` (impossible).

**Cas "vidange complète"** : `timeToGain(lvl, lvl)` donne le temps pour vider entièrement la jauge. C'est cette formule qu'utilisent à la fois l'affichage "Vide en" et le timer XP quand la cible est hors de portée d'une seule charge.

---

## 4. Niveau de jauge après X secondes — `gaugeAfter(lvl, sec)`

**Question** : "Si ma jauge démarre à `lvl` et tourne `sec` secondes, à quel niveau sera-t-elle ?"

Même logique que `gainedIn`, mais au lieu de compter les points donnés, on soustrait directement du niveau de la jauge.

---

## 5. Temps écoulé — `elapsed` et `elapsedLive`

### `elapsed(timer)` — temps figé pour l'affichage

Calcule les secondes écoulées depuis le démarrage du timer d'un enclos, en excluant les pauses. **Se fige** quand le timer est en pause ou terminé :

```
Si en cours     : (maintenant - démarrage - temps_en_pause) / 1000
Si en pause     : (moment_pause - démarrage - temps_en_pause) / 1000
Si non démarré  : 0
```

Utilisé pour : l'affichage "Temps écoulé", le dashboard.

### `elapsedLive(enc)` — temps réel après complétion automatique

Retourne un temps qui **continue de progresser en temps réel** même après que la session s'est terminée automatiquement. Cela permet aux jauges de continuer à se vider en arrière-plan après l'alarme.

```
Si enc.alerted['__done__'] est positionné :
    → (Date.now() - startTime - pausedMs) / 1000  (jamais figé)
Sinon :
    → elapsed(enc.timer)  (comportement normal, fige sur pause)
```

**`enc.alerted['__done__']`** est posé par la commande `complete-timer` quand toutes les cibles sont atteintes. Ce flag distingue une "fin naturelle de session" (jauges continuent) d'une "pause manuelle" (jauges figées).

Utilisé pour : tous les calculs de jauges dans `computeGaugeLive`, `enclosGaugeCurGl`, `calcSerenEtaLive`, `calcLevelEtaLive`.

---

## 6. Gel de jauge au cap absolu des stats et calcul par segments — `computeGaugeState`

### 6.1 Gel au cap absolu

Quand une **stat** atteint sa **limite absolue** (sérénité ±5000, endurance/maturité/amour 20 000), la jauge correspondante **s'arrête de se vider** — il n'y a plus rien à donner à la DD.

**Points jusqu'au cap** (`ptsToAbsCap`) :
- Stat normale : `dir>0 ? (statMax - startSt) : (startSt - statMin)` — la jauge continue **même après la cible**, jusqu'au cap absolu de la stat
- XP / mangeoire : `xpForLevel(200) - xpForLevel(niveauDépart)` — la jauge gèle uniquement au niveau 200, **jamais à la cible XP**

**Comportement après la cible** : les jauges ne s'arrêtent PAS quand la cible d'une DD est atteinte. Elles continuent de se vider jusqu'à ce que la stat atteigne son cap absolu (ex: sérénité à -5000, même si la cible était -60). Cela garantit que l'affichage reste cohérent et que les DDs continuent de progresser en fond.

**Affichage global de la jauge de stat (enclos)** : la jauge de stat se fige quand la **dernière** DD atteint son cap absolu. La mangeoire continue de se vider jusqu'au niveau 200.

### 6.2 Calcul par segments avec recharges — `computeGaugeState(startGl, recharges, ptsAllowed, el)`

Quand le joueur recharge une jauge en cours de session, le calcul se découpe en **segments** :

```
Segment 1 : startGl → recharge[0].atSec  (ou fin du timer si pas de recharge)
Segment 2 : recharge[0].level → recharge[1].atSec
...
Segment N : recharge[N-1].level → el
```

Pour chaque segment :
1. Calculer la durée du segment
2. Calculer les points gagnés dans ce segment avec `gainedIn`
3. Vérifier si le cap (`ptsAllowed`) est atteint → si oui, calculer le moment exact du gel et stopper
4. Si non, passer au segment suivant avec le nouveau niveau de jauge après recharge

**Retourne** : `{ gained, curGl, effectiveEl }`
- `gained` : points totaux accumulés sur tous les segments
- `curGl` : niveau de jauge au moment du gel (ou à `el` si pas de gel)
- `effectiveEl` : temps réel de fonctionnement (≤ el, limité par le gel)

---

## 7. Calcul unifié d'une jauge — `computeGaugeLive(enc, dd, gid, el, started)`

C'est le **cœur du système**. Pour chaque DD et chaque jauge active, il calcule en temps réel :
- La stat estimée actuelle
- Si la cible est atteinte
- Le % de progression
- Le countdown restant

> **Note sur le paramètre `el`** : bien que la signature accepte un paramètre `el` (elapsed), il est **ignoré en interne**. La fonction appelle systématiquement `elapsedLive(enc)` pour ses calculs, ce qui garantit que les jauges continuent de progresser après la fin de session. Le paramètre `el` est conservé dans la signature pour la compatibilité des appels depuis `enclosGlobalState`.

### Pour les jauges normales (sérénité, endurance, maturité, amour) :

1. On récupère le **snapshot** de la jauge et de la stat au moment du démarrage du timer
2. On appelle `computeGaugeState(startGl, recharges, ptsToAbsCap, elLive)` pour obtenir les points gagnés en tenant compte des recharges et du gel
3. On applique la direction (`dir`) :
   - `dir = +1` (caresseur, foudroyeur, abreuvoir, dragofesse) : stat monte
   - `dir = -1` (baffeur) : stat descend
4. On clamp la stat dans ses bornes (ex: sérénité entre -5000 et +5000)
5. On calcule le temps total et le countdown via `timeToGain`

### Pour la mangeoire (XP) — même modèle que les autres jauges :

La mangeoire se vide exactement comme les autres jauges (même tiers, même dégression). L'XP suit le même algorithme :

1. XP gagnée = via `computeGaugeState` (tient compte des recharges et du gel à niveau 200)
2. XP nécessaire = `xpForLevel(cible) - xpForLevel(départ)`
3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
4. Countdown = `timeToGain(curGl, Math.min(xpRestante, curGl))` — si la cible dépasse la capacité de la jauge, on affiche le temps de vidange complète

**Cible XP par défaut** : si `dd.levelTarget === null`, on cible le niveau 200.

**Exemple** : mangeoire à 85 000 (tier 3) pendant 7 000 sec (700 ticks) :
- 500 ticks à 30 XP/tick (tier 3, 70k→85k) = 15 000 XP, jauge → 70 000
- 200 ticks à 20 XP/tick (tier 2) = 4 000 XP, jauge → 66 000
- Total = **19 000 XP** (contre 21 000 XP avec un taux fixe erroné)

---

## 8. Table d'XP et niveaux

### Table XP_RAW

Dictionnaire de 200 entrées : `niveau → XP cumulatif total`.

Exemples :
- Niveau 1 → 0 XP
- Niveau 10 → 809 XP
- Niveau 50 → 34 365 XP
- Niveau 100 → 172 668 XP
- Niveau 200 → 867 582 XP

Les valeurs sont **cumulatives** (pas incrémentales).

### `xpForLevel(lvl)` : Retourne l'XP cumulatif pour atteindre le niveau `lvl`.

### `levelFromXp(xp)` : Retourne le plus haut niveau atteint avec `xp` points d'XP cumulatifs. Parcourt la table de 200 à 1 pour trouver le seuil.

---

## 9. ETA Sérénité — `calcSerenEta` / `calcSerenEtaLive`

### Version statique (`calcSerenEta`)
1. Calcule `diff = cible - sérénité actuelle`
2. Si `diff > 0` → besoin du caresseur (monte la sérénité)
3. Si `diff < 0` → besoin du baffeur (baisse la sérénité)
4. Temps = `timeToGain(niveauJauge, |diff|)`

### Version live (`calcSerenEtaLive`)
1. Utilise `computeGaugeState` pour obtenir la sérénité estimée en temps réel (avec recharges et gel)
2. Recalcule le diff depuis cette estimation
3. Calcule le temps restant avec les points encore à parcourir

---

## 10. ETA Niveau — `calcLevelEta` / `calcLevelEtaLive`

### Version statique (`calcLevelEta`)
1. XP nécessaire = `xpForLevel(cible) - xpForLevel(niveauActuel)`
2. Cible = `dd.levelTarget ?? 200`
3. Temps = `timeToGain(niveauMangeoire, Math.min(xpNécessaire, niveauMangeoire))`

### Version live (`calcLevelEtaLive`)
1. Cible = `dd.levelTarget ?? 200`
2. XP gagnée via `computeGaugeState` (avec recharges et gel)
3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
4. XP restante = `xpForLevel(cible) - xpForLevel(départ) - xpGagnée`
5. Countdown = `timeToGain(curGl, Math.min(xpRestante, curGl))`
   - Si XP restante ≤ capacité de la jauge : temps pour atteindre la cible
   - Sinon : temps de vidange complète (cohérent avec "Vide en")

---

## 11. Countdown global d'un enclos — `enclosGlobalState(enc)`

**Question** : "Dans combien de temps TOUTES les DD de cet enclos auront atteint TOUTES leurs cibles ?"

1. Pour chaque DD × chaque jauge active → appeler `computeGaugeLive`
2. Prendre le **maximum** de tous les countdowns = le plus long à terminer
3. Compter combien de DD ont TOUTES leurs cibles atteintes (`ddDone`)
4. `allDone = true` si **toutes les jauges actives** de **toutes les DDs** ont atteint leur cible

**Règle d'alarme unique** : la session se termine (`complete-timer`) une seule fois, au timer le plus long. Si un enclos a baffeur + mangeoire, l'alarme ne sonne que quand les DEUX cibles (sérenité ET niveau XP) sont atteintes pour toutes les DDs. Il n'y a pas d'alarme intermédiaire quand une seule cible est atteinte.

**Prévisualisation avant démarrage** : la fonction calcule même quand le timer n'est pas démarré (`started = false`, `el = 0`). L'"Alarme dans" et les timers DD se mettent à jour en temps réel dès que le joueur saisit une valeur de jauge ou une cible — sans avoir à démarrer le timer.

## 11 bis. Vérification globale de complétion — `checkAllEnclosCompletion()` dans `App`

La vérification de complétion ne dépend pas de la vue active ni du focus de la fenêtre.

**Pourquoi pas dans la boucle rAF ?** `requestAnimationFrame` se **suspend** quand l'application Electron perd le focus OS (ex : l'utilisateur alt-tab vers une autre application). Si la détection était dans la boucle rAF, la notification ne sonnerait jamais quand l'utilisateur est hors de l'app.

**Solution** : `checkAllEnclosCompletion()` tourne dans un `window.setInterval(..., 1000)` indépendant, démarré dans `App.render()` et nettoyé dans `App.destroy()`. `setInterval` continue de s'exécuter même quand la fenêtre est en arrière-plan.

**Algorithme** :
1. Parcourt **tous** les enclos en cours (`summary.running = true`)
2. Pour chacun, récupère l'état complet et appelle `enclosGlobalState`
3. Si `allDone = true`, exécute `complete-timer` → alarme + notification immédiate

**Double protection** : `EnclosView.update()` appelle aussi `complete-timer` quand `allDone && running` (quand l'utilisateur est sur l'enclos). Le handler `complete-timer` possède un guard `if (!enc.timer.running || enc.alerted['__done__']) return` pour éviter un double déclenchement.

---

## 12. Recharge de jauge en cours de session

Le joueur peut **recharger une jauge pendant que le timer tourne** (ex : remplir la mangeoire à mi-session).

### Enregistrement (`recharge-gauge`)

- Stocke `{ atSec: elapsed(), level: newLevel }` dans `timer.gaugeRecharges[gid]`
- Plusieurs recharges s'accumulent dans un tableau
- Le `reset-timer` vide tous les tableaux de recharge

### Impact sur le calcul

`computeGaugeState` segmente automatiquement le calcul entre chaque recharge :
- Segment 1 depuis `startGl` jusqu'à la première recharge
- Segment 2 depuis le nouveau niveau jusqu'à la recharge suivante (ou la fin)
- etc.

Le gel au cap absolu est vérifié dans chaque segment : si la stat atteint son cap avant la prochaine recharge, le gel est précis à la seconde près.

### Affichage

Les inputs de jauge affichent une bordure verte (classe `.gauge-inp-recharge`) pendant que le timer tourne, indiquant que toute nouvelle saisie sera interprétée comme une recharge.

---

## 13. Mises à jour en temps réel des inputs

Tous les champs de saisie (niveaux de jauges, stats des DD, cibles) déclenchent une mise à jour de l'état à **chaque frappe** via un listener `input`, en plus du `blur` final. Cela permet :

- L'"Alarme dans" de se recalculer instantanément pendant la saisie
- Les timers DD (XP, sérénité, etc.) de se mettre à jour à la volée
- Une expérience cohérente avant ET pendant le timer

Exception : les recharges de jauge ne sont déclenchées que sur `blur`/`Enter` (pas sur chaque frappe) pour éviter d'enregistrer des recharges partielles.

---

## 14. Arbre de réapprovisionnement — `calcAppro()`

**Question** : "Pour produire Q exemplaires de la race X, de quelles races et en quelles quantités ai-je besoin ?"

### Principe : décomposition récursive par génération

Chaque race de génération ≥ 2 est produite par le croisement de 2 races parentes (table `BREEDING_RECIPES`).

**Algorithme** :
1. On part de la race cible et de la quantité voulue
2. Pour chaque génération (de la plus haute à gen 2) :
   - Pour chaque race nécessaire à cette génération :
     - Calculer le nombre de couples nécessaires (voir §14.1)
     - Ajouter les parents nécessaires (chacun × nombre de couples) dans le pool
3. Les races gen 1 restantes = matières premières

### 14.1 Mécanisme du reproducteur

Un reproducteur est une DD réutilisable : elle peut faire plusieurs bébés.

```
Si 2×R ≥ Q  →  couples = ceil(Q / 2)
Sinon       →  couples = Q - R
```

Où `R` = nombre de reproducteurs, `Q` = quantité nécessaire.

---

## 15. Calcul d'inventaire avec contraintes ♂/♀ — `calcInventaire()`

**Question** : "Avec mon stock actuel de DD (mâles et femelles), quels croisements puis-je réaliser ?"

### Modèle de données
Chaque race dans l'inventaire : `{ m: mâles, f: femelles, n: neutres }`

### Algorithme : round-robin par génération

Pour chaque génération (2 → 10) :
1. Lister tous les croisements possibles à cette génération
2. **Boucle round-robin** : tant qu'au moins un croisement est possible :
   - Vérifier qu'on a 1 mâle-capable chez A ET 1 femelle-capable chez B (ou l'inverse)
   - Consommer les parents (priorité au stock réel m/f, puis les neutres n)
   - Ajouter 1 neutre (`n++`) à la race du bébé produit

### Priorité de consommation
```
takeMale   : si m > 0 → m--, sinon n--
takeFemale : si f > 0 → f--, sinon n--
```

---

## 16. Constantes des stats

| Stat      | Min    | Max    | Jauge(s) associée(s) |
|-----------|--------|--------|----------------------|
| Sérénité  | -5 000 | 5 000  | Baffeur (↓), Caresseur (↑) |
| Endurance | 0      | 20 000 | Foudroyeur (↑) |
| Maturité  | 0      | 20 000 | Abreuvoir (↑) |
| Amour     | 0      | 20 000 | Dragofesse (↑) |
| Niveau/XP | 1      | 200    | Mangeoire (↑) |

---

## 17. Système de snapshots

Quand le timer démarre (démarrage initial uniquement, pas lors d'une reprise de pause) :
- `snapGauges` : niveaux de toutes les jauges actives au moment du démarrage
- `snapStats[dd.id]` : stats de chaque DD au moment du démarrage
- `gaugeRecharges` : réinitialisé à `{}`

Tous les calculs utilisent ces snapshots comme point de départ. Une **reprise de pause** accumule uniquement `pausedMs` sans toucher aux snapshots.

---

## 18. Flux de session — enchaînement des sessions

### Session unique (sans jauges supplémentaires)
1. Timer démarré → `start-timer` prend les snapshots
2. Toutes les cibles atteintes → `complete-timer` déclenché
3. Session terminée : timer figé, bannière "Session terminée" visible, jauges continuent en fond
4. Clic "🔄 Nouvelle fournée" → `nouvelle-fournee` : reset complet + 1 DD neuve

### Session enchaînée (nouvelles stats à monter)
1. Session terminée (`alerted['__done__'] = true`)
2. Les **boutons de jauges sont déverrouillés** → l'utilisateur sélectionne de nouvelles jauges
3. L'utilisateur configure les niveaux de jauges pour la nouvelle session
4. Clic "▶ Démarrer" → `start-timer` détecte `alerted['__done__']` → **démarrage initial** (pas reprise de pause) → nouveaux snapshots pris avec les stats actuelles des DD
5. Nouvelle session démarre : les stats des DD reflètent les gains de la session précédente

**Règle de déverrouillage des jauges** : `locked = started && !enc.alerted['__done__']`
Les jauges sont verrouillées uniquement pendant une session active (running ou en pause manuelle). Elles sont déverrouillées une fois la session terminée automatiquement.

**Bouton timer** : affiche "▶ Reprendre" uniquement en cas de pause manuelle (`pausedAt` et `!alerted['__done__']`). Après complétion automatique, affiche "▶ Démarrer" (nouvelle session).

---

## 19 bis. Commande `nouvelle-fournee`

Remet l'enclos dans un état "vierge" pour une nouvelle fournée complète :
- Reset timer (efface startTime, snapshots, alerted, recharges)
- Remet tous les niveaux de jauges à 0
- Supprime toutes les DDs
- Ajoute 1 nouvelle DD avec les stats de base (`serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1`)

Distinct de `reset-timer` (qui remet seulement le timer à zéro, conserve les DDs et les jauges) et de `clear-enclos` (qui remet tout à zéro incluant les jauges actives et le nom).

---

## 18. Cycle de vie de `complete-timer`

La commande `complete-timer` représente la **fin naturelle d'une session** (toutes les cibles atteintes). Elle est distincte d'un simple `stop-timer` (pause manuelle).

### Ce que fait `complete-timer`
1. **Guard** : si `enc.timer.running = false` ou si `enc.alerted['__done__']` est déjà posé → retourne immédiatement (idempotent, pas de double alarme)
2. Pose `enc.timer.running = false`
3. Pose `enc.timer.pausedAt = Date.now()` — gèle `elapsed()` à l'instant de complétion
4. Pose `enc.alerted['__done__'] = true` — active le mode "continuation en fond" dans `elapsedLive`
5. Persiste l'état via `repo.save()`
6. Émet l'événement `timer-completed` → déclenche alarme audio + notification Windows/mobile

### Différence avec `stop-timer` (pause manuelle)
- `stop-timer` : pose `running = false` et `pausedAt` mais **ne pose pas** `alerted['__done__']`
- Conséquence : `elapsedLive` retourne `elapsed()` figé → toutes les jauges se figent sur la pause
- `complete-timer` : `alerted['__done__'] = true` → `elapsedLive` continue en temps réel → jauges continuent de se vider en fond

### Après `complete-timer`
| Élément | Comportement |
|---------|-------------|
| Affichage "Temps écoulé" (enclos + dashboard) | **Figé** — utilise `elapsed(timer)` = `(pausedAt - startTime - pausedMs) / 1000` |
| Jauges (niveau, barre) | **Continuent** — `enclosGaugeCurGl` utilise `elapsedLive` |
| Stats estimées des DD | **Continuent** — `computeGaugeLive` utilise `elapsedLive` |
| Countdown "Alarme dans" | Affiche `✅` |
| Bannière "Session terminée" | Visible dans la vue enclos |

---

## 19. Animations et détection de tick — `DragodindeCard`

### Détection de tick

Un "tick" survient toutes les 10 secondes. La carte DD détecte un nouveau tick en comparant `Math.floor(elapsedLive(enc) / 10)` avec la valeur précédente.

Utiliser `elapsedLive(enc)` (et non `elapsed`) garantit que les animations de tick **continuent** après la fin de session, le temps que toutes les jauges se vident en arrière-plan.

### `deltaActive` — quand afficher l'animation de delta

L'animation de delta ("+20 xp", "-10 sérenité"…) s'affiche à chaque tick **uniquement si la jauge est encore active**. Une jauge est considérée inactive quand la stat atteint son **cap absolu** (pas sa cible) :

```
Pour les jauges de stats : atAbsCap = (estStat >= statMax)  ou  (estStat <= statMin)
Pour la mangeoire (XP)  : atAbsCap = (estLevel >= 200)
```

Cela signifie que l'animation continue après la cible de l'utilisateur (ex: sérénité cible -60 → animation continue jusqu'à -5000).

### Badge "✓ TERMINÉ"

Affiché sur la carte DD quand **toutes les jauges actives** ont `done = true` pour cette DD. Les jauges de stats et la mangeoire comptent toutes de la même façon — pas de distinction.

---

## 20. Événements domaine actifs

| Événement | Déclencheur | Effet |
|-----------|-------------|-------|
| `timer-completed` | `complete-timer` (toutes cibles atteintes) | Alarme audio + notification Windows + notification mobile ntfy |
| `gauge-threshold-reached` | Franchissement d'un palier de jauge | (usage interne) |
| `accouplement-registered` | Enregistrement d'un accouplement | Mise à jour des stats globales |
| `enclos-deleted` | Suppression d'un enclos | Nettoyage de l'état |

**Événements supprimés** (ancienne implémentation, ne plus utiliser) :
- `target-reached` — ancienne alarme intermédiaire quand une cible de stat était atteinte avant les autres
- `xp-target-reached` — ancienne alarme séparée pour la cible XP

Ces événements ont été remplacés par la règle d'alarme unique : **une seule alarme au timer le plus long**, déclenchée par `timer-completed` uniquement.
