Imported from eternel7/telluris (
.claude/skills/telluris-combat/SKILL.md). Install upstream withnpx skills add eternel7/telluris --skill telluris-combat. Copyright stays with the author.
Échelle des caractéristiques (×10, sauf V)
7 caractéristiques à l'échelle ×10 (F/R/Ag/Vol/Int/Cha/Ch), V à l'échelle 1-10 (deplacement = V cases/tour). Formules dérivées (cc, cd, initiative, actions_max), partagées joueur et monstres, dans models/character_stats.py — Ag pèse le plus lourd dans ces formules. Couvert par tests/test_character_stats.py.
Plafonds de caractéristique — quota racial + dépassement
compute_stat_cap (models/character_stats.py) donne le plafond effectif d'une caract : plein tarif racial, sous-plafond une fois le quota de caracts déjà maxées atteint (nb_max_accessibles), ou plafond relevé si le bonus de dépassement a été engagé sur cette caract précise — formules et quota couverts par tests/test_character_stats.py.
Le dépassement est un trait de DONNÉE, pas de code : une race le porte via max_bonus:{caract:Δ} sur rules:races (seul l'humain l'a : +10, +1 pour V). Unique et irréversible par personnage : character["max_bonus_used"] mémorise la caract choisie (absent ⇒ jamais utilisé, aucune initialisation à la création). POST /api/spend_xp prend un booléen use_max_bonus — 422 si déjà consommé ou si la race n'a pas de bonus —, arme le plafond avant de valider la montée, puis persiste le champ. ⚠️ Le bonus ouvre le plafond, jamais le tarif : compute_xp_cost est inchangé. Passe par _acteur ⇒ un compagnon humain y a droit, sur SON doc.
Payload : utils/fiche.bloc_fiche publie max_bonus + max_bonus_used à côté de stat_caps (source unique ⇒ contexte /play et fiche de compagnon, qui ne reçoit pas race) ; spend_xp renvoie max_bonus_used.
UI (play_town_telluris.html, onglet ⚔) : bouton ✦ Dépasser +Δ (.sh-bonus-btn, violet pour ne pas se confondre avec le doré du bouton d'XP) offert sur les seules caracts BLOQUÉES (val >= cap : au max racial, ou coincées au sous-plafond par le quota), tant que le bonus est libre. Il ouvre #max-bonus-overlay, qui appelle spendXp(code, true). ⚠️ Le bouton est rendu même quand il n'est pas offert, en display:none : une caract peut se bloquer en cours de session (montée au max, ou quota qui se remplit et rabaisse un autre plafond) et _refreshStatBoxes l'allume par getElementById — sans l'élément, il n'aurait rien à allumer ; son onclick est donc posé en JS, pas seulement en Jinja.
⚠️ Jauge : l'échelle et le repère sont DEUX valeurs distinctes. échelle (100 % de la barre) = max(maxRace, cap), sans quoi le remplissage déborderait une fois le plafond dépassé. Le pip = min(cap, maxRace) : il reste planté au max naturel de la race, c'est le repère que le remplissage doit visiblement franchir ; il ne descend en dessous que sous l'effet du quota (.subcap, grisé). Les faire bouger ensemble (pip = cap) rend le dépassement invisible. Trois sites à tenir d'accord : le rendu Jinja (echelle/pip_val), _refreshStatBoxes et _renderCaractsMod (qui réécrit la même largeur de remplissage depuis le total buffé).
Combat & butin (utils/combat.py + routers/combat.py)
Un combat est un doc combat:* (snapshots joueurs[]/monstres[], ordre_initiative, battle_map_id), stats de monstres dérivées de espece:* modulées par profil:*. finalize_combat() applique XP/PV et le butin de façon idempotente (character["combats_recompenses"]). Le butin de victoire n'est jamais auto-ajouté (butin_disponible proposé dans l'overlay, encaissé par POST /api/combat/{id}/collect, borné par la charge) ; ce que personne n'emporte est versé au sol par le chokepoint combat.verser_butin_au_sol plutôt que perdu, sous la même garde d'idempotence que l'encaissement — y compris pour la cargaison d'une monture tombée, quelle que soit l'issue du combat. Couvert en profondeur par les ~16 fichiers tests/test_combat_*.py et tests/test_butin_au_sol.py.
Échanger sa place avec un allié hors tour (action deplacer) : un acteur jouable: False (monture, personne escortée) est compté par _occupied_set mais n'a NI tour NI budget — il enfermait le joueur pour tout le combat. Le pas vers sa case PERMUTE les deux, au prix d'un pas ordinaire. ⚠️ _allie_echangeable teste jouable is False strictement : un joueur ordinaire n'a pas la clé, et un test falsy rendrait tout le groupe échangeable (un compagnon jouable garde sa case, il a son tour pour s'écarter). ⚠️ _echange_possible est symétrique — chacun doit tenir sur la case de l'autre (_walkable avec le vol de celui qui ARRIVE), la jambe aller étant déjà la garde de terrain existante ; aucun second contrôle nav, get_final_mask étant bidirectionnel. ⚠️ L'échangé est passé à _avec_etat lui aussi : absent du journal, son jeton suivrait l'état final tout de suite pendant que celui du joueur attend la révélation — les deux corps ne glisseraient pas ensemble. Prédicat client en double (echangePossible dans scripts/deplacement.js, consulté par occupantEchangeable/dirAvailable), comme cellOccupied face à _occupied_set. Couvert par tests/test_combat_echange.py et dev/test_deplacement_client.js.
Dégâts — chokepoint calculer_degats + localisation des touches
combat.calculer_degats(...) est la SEULE formule de dégâts du jeu (dés × multiplicateur de critique, puis soustraction des PA sauf en magique), traversée par tous les sites qui frappent et ceux qui l'ESTIMENT (simulateur, potentiels) — une copie locale ferait diverger le jeu de son propre banc d'essai. Un d100 décide où le coup porte (tirer_localisation, table LOCALISATION_TOUCHES en bornes hautes cumulées, table vide ⇒ PA agrégés) et seuls les PA de la zone touchée s'appliquent, plus ceux qui protègent partout (pa_hors_zone, tout slot absent de ZONE_SLOT). Formule, résolution dynamique de des_fn, localisation et ventilation des PA par équipement sont couverts par tests/test_combat_degats.py et tests/test_combat_localisation.py.
⚠️ Nerf massif et VOLONTAIRE de l'armure portée : un chevalier en plates passe de 58 PA partout à 17-30 selon la zone. À calibrer depuis /admin/simulateur, avec FACTEUR_DEGATS_ARMURE comme second levier. Le 12ᵉ emplacement epaules est né de là (une zone sans pièce n'a rien à défalquer) — quatre sites à tenir d'accord : models/character_document.py, la création et _VALID_SLOTS de routers/user.py, SLOT_LABELS de play_town_telluris.html ; contenu par dev/gen_epaulieres.py.
Attaques par mode — corps à corps / jet / tir
Mode déterminé par tag : tir (arcs), jet (lancer), sinon cac (hast = cac de portee ≥ 2). _weapon_attacks produit joueur["attaque_profils"] (1 par mode, poings toujours présents) ; resolve_action sélectionne le profil. Restrictions d'équipement, règles ranged (interdit si engagé, ligne de vue requise) et allonge sont couvertes par tests/test_combat_ranged.py et tests/test_competences.py.
UI : chaque mode est une entrée assignable de la barre de slots ({type:"attaque", ref:"cac"|"jet"|"tir"}) — pas de sélecteur de mode ni de boutons d'attaque fixes. ⚠️ Une case d'attaque garde sa position et se grise quand le mode n'est pas équipé (modesEquipes) ou n'a aucune cible (targetableMonsters(mode)) : la faire disparaître décalerait les cases voisines d'une arme à l'autre, ruinant la mémoire musculaire. triggerAttack(mode) fixe currentMode puis attaque directement (1 cible) ou ouvre le sélecteur de cible (les cases d'attaque s'effacent en visibility via .hidden-selecting, la grille gardant sa forme). Les badges du bandeau d'initiative (.init-badge.monstre) servent aussi de ciblage — bordure grise hors portée, rouge + cliquables uniquement quand attaquables (tour + action + portée, refreshBadgeTargets), miroir du clic sur .monster-token.adjacent. ⚠️ Le bandeau DÉFILE horizontalement, il ne wrappe plus (flex-wrap:nowrap + overflow-x:auto) : passé quelques ennemis, les retours à la ligne faisaient grandir le header et repoussaient la carte hors de l'écran. Deux points à ne pas défaire — min-width:0 (un item flex vaut min-width:auto, sans quoi le bandeau déborde au lieu de défiler, ses deux voisins ne cédant rien : .tour-label est nowrap, .son-btn flex-shrink:0) et un padding vertical (dès qu'overflow-x cesse d'être visible l'axe vertical est clippé lui aussi, et le badge .active déborde de sa case : scale(1.12) + halo de 9 px).
UI panneaux combat (combat_telluris.html) : panneau perso repris du <section class="character-panel"> de play_town — portrait recadré (#left-portrait.pt-crop via applyPlayerPortrait, carré, barres PV/PM verticales), identité, pips d'actions + jauge de charge. Vie des ennemis = anneaux PV sur les badges du header (setRing/MONSTER_CIRC) + grand portrait du dernier ciblé. Carte iso : MAX_H=10 rangées, --step borné à la seule largeur (tailleVue : pas ENTIER floor(w/17) − 1, plancher 8) → hauteur agrandie sans toucher la largeur, page défilable. ⚠️ syncViewSize est idempotente (CLAUDE.md §17) : son --step change la hauteur, donc la barre de défilement, donc la largeur mesurée — le ResizeObserver faisait osciller le pas d'un tick à l'autre. Elle mesure la conséquence de sa propre écriture (conflit ⇒ le plus petit pas), mémorise les largeurs résolues (_vueResolue) et n'écrit / ne coupe l'animation / ne replace les jetons que si le pas change vraiment (_surRedimensionnement). Verrouillé par dev/test_vue_combat_client.js.
Barre d'action de combat — slots configurables
Un nombre FIXE de cases dont le joueur décide une à une du contenu ; c'est le seul accès aux sorts, compétences et consommables en combat (sauf à passer par ⚙, au prix d'une action). Logique pure utils/slots_actions.py. Champ character["slots_actions"] = liste dense positionnelle de longueur COMBAT_SLOTS_MAX (portée aussi bien par character:* que par aventurier:*, chaque membre du groupe a SA disposition), avec l'invariante des trois actions obligatoires (ENTREES_OBLIGATOIRES : mêlée, ramasser, fuir — toujours présentes, réparées à la lecture) et la migration à la lecture (ENTREES_DERIVEES/_slots_derives) pour tout doc sans le champ. Le flag composants d'une entrée de sort, la résolution d'un consommable par item_id (jamais par index d'instance), et le coût d'une action pour entrer en mode ⚙ (editer_barre, compté et remis à zéro au reset de tour) sont couverts par tests/test_slots_actions.py, tests/test_combat_slots.py et dev/test_slots_client.js (ce dernier couvre aussi la résolution de l'acteur courant côté client — acteurCompagnonId() — dont le repli pendant un tour de monstre doit rester cohérent avec le serveur).
Mise en page par WRAP, pas par grille figée (.slot-grid = flex + flex-wrap) : la case vaut clamp(28px, (100% − 45px)/10, 46px), soit 1/10 du conteneur plafonné à 46 px. Sur téléphone (~390 px) cela fait exactement 10 cases par rangée ; au-delà d'environ 1015 px les 20 tiennent sur une ligne. Aucune media query de mise en page — la largeur de la case suffit à décider ; les tailles intermédiaires wrappent librement (assumé).
Endpoints (routers/user.py) : POST /api/slot_action {position, entree|null} (poser/vider) et POST /api/slot_deplacer {source, cible} (échange — seul geste capable de bouger une obligatoire, d'où l'échange plutôt que l'écrasement). Les deux renvoient {slots} recalculé. Payloads : slots/slots_max dans le contexte /combat/{id} (main.py) et GET /api/combat/{id}/acteur ; ⚠️ barre_slots/barre_slots_max dans utils/fiche.bloc_fiche — la clé slots y désigne déjà l'ÉQUIPEMENT du compagnon (_recrue_view fusionne le bloc), l'écraser viderait son paperdoll.
UI combat : renderSlots() ; activerSlot(slot) aiguille seulement — toute la logique (cast direct vs ciblage violet, portée) reste dans triggerAttack/startSortCast/startCompUse/doAction, slotComposants décidant seulement de ce qui est engagé. slotJouable pilote updateButtons en réutilisant tels quels sortCastable/compCastable/canLoot/canConsume/targetableMonsters. #target-row sert à la sélection de cible d'une attaque. Mobile : ordre totalement inversé 20→1 pour amener le slot 1 sous le pouce — deux variables CSS posées par case (--o desktop, --o-rev mobile) plutôt qu'un recalcul au resize.
Glisser-déposer = CLIC LONG, gratuit, hors mode ⚙ (attacherDragLong, ~400 ms) : Pointer Events et non drag & drop HTML5, qui ne fonctionne pas au doigt. Un mouvement > 10 px avant l'armement annule (c'est un défilement) ; touch-action:none sur la case empêche le défilement de voler le geste ; un drapeau dragJustEnded avale le click qui suit, sinon relâcher déclencherait l'action de la case. Réordonner sa barre sous le feu est libre — seul changer le contenu d'une case coûte.
⚠️ Côté client, doAction retourne son data et toggleSlotEdit teste action_result.error : un refus du moteur revient en 200, pas en 4xx — tester res.ok seul ouvrirait le mode sans avoir payé. Cas limite assumé : entrer avec exactement 1 action restante termine le tour. #btn-slot-assign/#btn-slot-done sont réactivés explicitement par updateButtons — ce sont des .action-btn, que doAction désactive en bloc.
UI fiche (play_town_telluris.html, onglet ⚡) : grille éditable #sh-slot-grid (même clic long, _attacherDragLong) + un <select class="sh-slot-select"> par compétence active / consommable de combat, et par mode d'attaque optionnel (section #sh-modes-attaque, _renderModesAttaque appelée par _renderBarreSlots → resync à chaque écriture) : ⚔ n'y figure pas (obligatoire, donc toujours posé), 🪃 et 🏹 s'y assignent sans avoir l'arme en main. Un sort à composants consommables porte DEUX sélecteurs (sans + / avec +). Configurer y est toujours gratuit — le coût en action ne concerne que le mode ⚙ du combat. ⚠️ Deux collisions de noms à ne pas rejouer : les globales sont préfixées _barreSlots (_slots = slots d'ÉQUIPEMENT du paperdoll) et les classes CSS .sh-barre-* (.sh-slot = emplacement d'équipement de la silhouette ; réutiliser ce nom faisait fuir aspect-ratio/curseur/police sur le paperdoll).
Jetons de taille variable — emprise, pivot, forme
Un acteur peut occuper un RECTANGLE de cases. Géométrie pure utils/jetons.py, miroir client templates/scripts/jetons.js (sans pathfinding : seul le serveur déplace un grand acteur). Champ absent ⇒ 1x1 rond, à la lettre (aucune migration ; un snapshot d'espèce sans gabarit n'a même pas la clé).
- Donnée :
espece:*→jeton: {"taille": "LxP", "forme": "ellipse"|"rectangle"|"triangle"}.L= largeur EN TRAVERS de la marche,P= profondeur DANS le sens de la marche (cheval 1x2, envergure d'archange 2x1, dragon 3x2). Tailles admises 1x1/2x1/1x2/2x2/3x2 (inconnue ⇒ 1x1), forme absente ⇒ ellipse. La forme n'habille que le dessin : les cases occupées sont toujours le rectangle plein. Contenu :dev/gen_jetons_especes.py(table EXHAUSTIVE des espèces, échoue sur une espèce non classée). ⚠️ L'éditeur/admin/bestiairereconstruit le doc (PUT complet) : ses deux sélecteurs taille/forme sont ce qui empêche une sauvegarde d'effacerjeton. - Snapshot :
jeton: {largeur, profondeur, forme}+cap(haut/bas/gauche/droite, repère MONDE).pos= coin haut-gauche. Cap horizontal ⇒w = P, h = L.capest dansCHAMPS_ETAT(le pivot se révèle avec le glissement). Porteurs : espèces seulement — monstres, élites, invocations (héritent du snapshot monstre) et montures, dont le gabarit est relu surespece:*danscreate_combat_doc(commecharge_max_porteur). Personnages, compagnons, escortés : 1x1. - Chokepoints :
_cheby=jetons.distance(Chebyshev entre EMPRISES) — toutes les portées et adjacences du moteur d'un coup ;_vue_acteurs= ligne de vue si AU MOINS UNE paire de cases se voit (remplace les_line_of_sightpos→pos) ;_occupied_setajoute toutes les cases d'une emprise. Zones : ancre =case_proche(cible, lanceur), victime prise dès qu'UNE case est dans la forme. - Pivot (
pas_jeton) : un pas cardinal qui change d'axe fait d'abord PIVOTER (bord avant +1 dans le sens du pas, centré en travers — arrondi bas puis haut), sinon translate SANS pivot (cap inchangé, la bête se décale de côté) ; un pas diagonal translate. Validité : cases praticables et libres, et chaque case entrée reliée à l'ancienne emprise parnavde proche en proche.chemin_jeton= A* sur (ancre, cap), butdistance ≤ portée, borné parEXPANSIONS_MAX. ⚠️ Un monstre 1x1 garde_find_pathtel quel (focalisation comprise) ; il vise seulement lacase_proched'une grande cible au lieu de son coin. - ⚠️ Traversée : l'échange de places est géométriquement impossible avec un 2x2 — un joueur JOUABLE traverse les cases d'un grand allié
jouable: False(_traversable_par,_occupied_set(traversant=…)), les monstres jamais. L'échange 1x1 reste, mais refusé si la case du joueur est couverte par un grand allié (les deux bêtes se superposeraient). Client :cellOccupied/occupantEchangeable. - Placement :
need= somme des aires ;_poser_empriseessaie deux caps tournés vers le central ; rien ne tient (carte minuscule, repli « premiers sols ») ⇒ l'acteur perdjetonet est posé en 1x1 pour ce combat (fail-soft). Invocations : même règle via_cases_invocation(…, jeton). Simulateur :_poser_positionspose l'adversaire au BORD de l'emprise, sinon un grand jeton raccourcirait la distance saisie. - Rendu (
.gabarit,_placerGabarit) : boîte--jw × --jhcases posée au CENTRE de l'emprise,bottomrecentré sur le pivot, et SANS lerotate(-a)final — la forme tourne avec le décor ; seul.jt-debout(portraitcontain+ PV) est contre-tourné. Forme =.jt-forme(bordure) +.jt-fond(translucide, l'aperçu de zone doit se voir au travers) ; triangle =clip-pathSVG#clip-jeton-<cap>enobjectBoundingBox, pointe vers le cap. ⚠️ Halo enfilter: drop-shadowsur la BOÎTE (une ombre sous un clip est rognée). Grand allié non jouable en z-index 1, sous les autres alliés.jouerVfxvise les centres d'emprise. - Verrouillé par
tests/test_jetons.py,tests/test_combat_jetons.pyetdev/test_jetons_client.js(mêmes cas d'emprise/distance que le pytest, + traversée extraite du template). ⚠️ Hors portée des tests : le rendu — à voir en jeu.
Critiques pilotés par la Chance
Tout jet d100 offensif passe par _seuils_critiques/_resoudre_jet (utils/combat.py) : la Chance élargit symétriquement la fenêtre de réussite critique et repousse celle d'échec critique, bornées par les world-vars CRIT_REUSSITE_MAX/CRIT_ECHEC_MIN. Un critique double les dés avant soustraction des PA ; un échec critique coûte une action de plus (immédiate ou en dette au tour suivant). Couvert par tests/test_combat_crit.py.
⚠️ Données : beaucoup d'espece:* ont encore Ch = {min:0, max:0} → delta large en faveur du joueur, à peupler.
Effets à durée
Un effet à durée est un effet à durée, qu'il vienne d'un sort, d'une compétence, d'une potion ou d'un coup d'arme ; sur soi, sur un allié ou sur un ennemi ; en exploration ou en combat. Format unique {buffs:{caract:Δ}, regen_pv, regen_pm, esquive, furtivite, duree} (+ pv/pm/degats pour l'instantané). Logique pure utils/consommables.py + utils/sorts.py.
Trois sources, un chokepoint
consommables._sources_de_buffs replie trois origines (effets_actifs[] temporaires, equipment_bonus d'équipement, competences_bonus passif) en caracts_avec_buffs/regen_bonus/esquive_bonus, effectifs partout sans autre code. V est buffable à son échelle 1-10, planché à 0 (deplacement = max(1, V)). Couvert par tests/test_consommables.py et tests/test_effets_equipement.py.
UI « ✨ Profil modifié » : caracts_detail(character) ventile chaque stat par origine, rendue dans la 2ᵉ grille de l'onglet ⚔ (#sh-mod-grid, client _renderCaractsMod), resynchronisée par la quasi-totalité des actions de fiche.
Non-cumul — deux règles
Elles ne visent que les effets_actifs ; équipement et passives restent additifs. (1) Une source = une entrée : relancer le même sort/potion remplace l'effet en place (chokepoint poser_effet, identité par cle_source). (2) Sur une même caract, rien ne s'additionne : meilleur bonus + pire malus se contrent, jamais ne s'additionnent (chokepoint cumul_effets), même règle pour regen/esquive (max). Couvert par tests/test_effets_non_cumul.py.
Consommables
Item categorie:"consommable" + champ effets : pv/pm instantanés, buffs/regen_* posés sur effets_actifs[]. Exploration POST /api/consommer, combat action "consommer" (1 action). UI : bouton 🍽️, chips ✨. Couvert par tests/test_consommables.py.
En combat — le snapshot est RECALCULABLE
build_joueur_snapshot/build_monster_snapshot portent caracts_base + effets_actifs vivants ; _refresh_snapshot_stats recompose toutes les dérivées à chaque effet posé/expiré, sauf trois valeurs figées à l'entrée (actions_max, charge_max, attaque_profils — anti-exploit et coût de relecture). Durée comptée en tours du porteur (_tick_effets_combat, hooké dans _reset_turn_budget), effets entrants tickent aussi, effets restants remontent sur le doc personnage en fin de combat (écrasement, jamais extend). Couvert par tests/test_combat_effets.py.
UI : chips ✨ #joueur-effets (renderEffetsActifs). ⚠️ Hors périmètre : le client n'affiche les chips que pour le joueur — un ennemi debuffé ne se voit que dans le log.
Sur PLUSIEURS cibles — zones d'effet
Une capacité portant un bloc zone (cercle / carré / rectangle / cône, ancrés sur le lanceur ou sur la cible, orientés par l'axe de visée ou par le facing) atteint tout son camp opposé — ou tout son camp — pris dans la forme. Offensif : les branches sort et competence de resolve_action partagent un seul chokepoint — _resoudre_capacite_offensive → _resoudre_coup_capacite —, leurs libellés restant portés par TEXTES_SORT/TEXTES_COMPETENCE : un jet, une localisation et une part durative PAR victime, mais un seul débit de PM/action et un seul fumble possible (celui de la cible désignée). Bénéfique (cible allie ou soi) : beneficiaires_de_zone → _servir_zone_soutien → _appliquer_soutien, extrait de _lancer_sur_allie qui en garde les gardes. Géométrie, contrat de donnée, aperçu client et limites (les deux camps ne se mélangent jamais) : compétence telluris-magie § Zones d'effet. Verrouillé par tests/test_combat_zone.py.
Sorts MAINTENUS — une durée qui n'est pas un compte à rebours
Une entrée d'effets_actifs posée par une capacité à maintien > 0 porte maintenu: True et _tick_effets_combat NE LA DÉCRÉMENTE PAS (même exemption qu'une entrée posée au tour courant). Elle ne tombe que si son lanceur cesse de payer l'entretien en PM, prélevé par _payer_maintiens au début de CHACUN de ses tours — après la remise du budget, et sans coûter le moindre PA. Chokepoint d'arrêt unique _rompre_concentration ; _effets_a_reverser (source unique des trois sites de finalize_combat) les jette à la sortie du combat. Détail, arithmétique de l'incantation multi-round et test de concentration : compétence telluris-magie § Les trois notions du temps magique. Verrouillé par tests/test_combat_maintien.py et tests/test_combat_incantation.py.
Un coup peut faire tomber DEUX corps — lien de vie
Depuis le lien de vie, _do_attack_on peut abattre le défenseur et son protecteur. Le bloc KO est extrait en _traiter_ko, appelé une fois par corps, avec est_joueur RECALCULÉ depuis la victime ; la déclaration de défaite y a gagné une garde status == "active". Un tour de monstre déclenche aussi un test de concentration sur chaque corps qui a réellement perdu des PV. ⚠️ _do_attack_on reste le SEUL endroit où un acteur du camp du joueur perd des PV sous un coup — _resoudre_coup_capacite et _frapper_monstre ne frappent que des monstres, et utils/simulateur applique les siens en parallèle (ni lien ni concentration au banc d'essai). Verrouillé par tests/test_combat_lien_vie.py.
La CHARGE portée renchérit la magie
Le snapshot porte charge_magique et canalisation à côté de charge/charge_max : le ratio des PM de lancement et de l'entretien se lit là, jamais en base (règle absolue de la résolution d'un coup). _ajuster_charge_magique les suit aux trois sites qui bougent charge (ramassage, consommation, composants). Chokepoints : _cout_pm_charge (gardes + débit) et _maintien_du (entretien + pénalité de concentration). Règle, courbe, deux charges séparées et UI : compétence telluris-magie § Charge portée → canalisation du mana. Verrouillé par tests/test_charge_magie.py, tests/test_combat_maintien.py et dev/test_charge_magie_client.js.
Sur une CIBLE ENNEMIE (debuffs)
Chokepoint _appliquer_effet_sur_cible(..., hostile=True) : posé seulement si le jet touche, jamais sur une cible que le même coup vient d'abattre, ne remonte sur aucun doc. Un sort/une compétence peut n'être QUE du debuff (degats OU part_durative). Couvert par tests/test_combat_debuffs.py.
Sur un ALLIÉ — cible: "allie"
Vise un compagnon ou une monture, sans jet de toucher. Combat : chokepoint _lancer_sur_allie (soin/buff clampés aux max de la cible). Exploration : _cible_alliee (le lanceur est testé en premier, pour ne jamais recharger un second dict du document qu'il vient de muter), _save_cast persiste jusqu'à trois docs dédoublonnés par identité. Couvert par tests/test_combat_allie.py et tests/test_cible_allie_exploration.py.
UI fiche : overlay #cible-allie-overlay alimenté par _alliesCibles(). ⚠️ La cible est demandée AVANT l'envoi (les PM partent à l'envoi) → Échap doit résoudre la promesse à null (Conventions §8).
UI combat : allyTargets(s)/capaCibles(c) aiguillent ciblage ennemi/allié pour les trois endroits qui doivent s'accorder (case grisée, jetons, badges) — ciblage VERT là où l'offensif est violet. ⚠️ .ally-token.spellcast doit porter pointer-events: auto : #tokens-layer est en pointer-events: none et chaque jeton le rouvre pour lui-même — sans cette ligne, l'onclick était posé mais jamais reçu, et le défaut est resté longtemps invisible parce que le badge du bandeau, lui, fonctionnait (on pouvait soigner par la liste et pas sur la carte). ⚠️ Les acteurs HORS TOUR (monture, personne escortée) ont eux aussi un badge, rendu en fin de bandeau, filtré sur jouable (selectattr('jouable','defined') D'ABORD, jamais active, background-size:contain sur leur portrait entier). Contenu : jsons/sorts_allies_a_importer.json.
Portés par une ARME (effets sur un item:*)
Un item:* de catégorie arme peut porter le même bloc effets + un cible top-level (bolas qui entravent, lame avide qui nourrit). Seule la part durative est exploitée (les dégâts passent par bonus_degats). Le profil d'attaque transporte l'effet depuis l'équipement au snapshot ; identité par l'_id de l'arme (relance au lieu d'empiler). ⚠️ Hors périmètre : les attaques de monstre ne portent pas de profil d'arme. Couvert par tests/test_armes_effets.py.
Animations de combat (feuilles de sprites + sons)
Ce qui se joue quand un coup se résout : un sprite, un son, ou les deux. Logique pure utils/animations.py, admin routers/animations.py + /admin/animations, lecture dans combat_telluris.html. Aucune migration nulle part. Le journal est le seul canal possible entre serveur et client (une entrée est {tour, acteur, kind, texte}, un tour de monstre étant résolu entièrement côté serveur) : le serveur pose donc une clé vfx = {anim, cible, acteur?} sur chaque entrée (combat._avec_vfx, chokepoint unique). Le schéma du doc animation:* (grille, découpe frame_position sur deux axes indépendants, décalages avec base commune) et la cascade de liaison par canal (sort:*/competence:*/item:*/espece:*, repli sur COMBAT_ANIMATIONS_DEFAUT, aiguillage du canal par type de coup) sont couverts par tests/test_animations.py. ⚠️ Aucune lecture DB pendant la résolution d'un coup : l'arme copie son animation sur le profil d'attaque au snapshot, le monstre depuis espece:*, sort/compétence/consommable arrivent déjà chargés par le router.
Trajectoire & rotation — une animation peut VOYAGER (flèche, boule de feu, caillou lancé) : depart_ancrage ("" = aucune trajectoire, sinon acteur/cible — l'ancrage existant devenant l'ARRIVÉE), depart_decalage_x/_y, duree_trajet_ms, arc (cloche en cases, à l'écran), rotation (⚠️ 0° = le dessin pointe vers la DROITE, une flèche dessinée vers le haut se corrige par 90) et rotation_auto.
- ⚠️
duree_ms≠duree_trajet_ms: la première est la durée d'UNE passe d'images, la seconde le temps de vol, sur lequel les images bouclent — sinon un projectile de 4 images volant une seconde défilerait au ralenti. Le sprite meurt à la fin du vol (donc àduree_mssans trajectoire : rien ne change pour l'existant). - ⚠️ Repli FAIL-SOFT sur « posé », jamais sur « rien » : départ absent, id manquant, acteur disparu ou confondu avec l'arrivée ⇒ sprite joué sur place. Corollaire : les canaux
buff/debuff,dissipationetconsommablen'ont pas d'acteur distinct dansvfx(l'effet s'applique au sujet lui-même) — une trajectoire liée à l'un d'eux ne volera jamais, et c'est correct. Tous les canaux offensifs portent bien l'acteur. - ⚠️
rotation_autooriente même SANS trajectoire : l'angle est alors celui de l'axe acteur → cible, ce qui tourne un impact vers celui qui frappe. La DIRECTION est donc portée à part de la POSITION (ctx.dirx/diry) : les deux ne coïncident pas toujours. - ⚠️ DEUX espaces, et c'est toute la difficulté : la position est un vecteur MONDE (il passe par le
rotate(a)de la caméra, donc la trajectoire tourne avec le décor, comme les jetons) ; décalages et arc sont de l'ÉCRAN. Tout tient dans UN seultransform(_poserVfx, plus aucunleft/bottom) :translateX(-50%) translate(ÉCRAN) rotate(a) translate(MONDE) rotate(angle). Le net visuel d'une chaîne CSS étant la somme de sesrotate, le terme final s'écritrotation + (auto ? θ_écran : 0) − a, et redonne−a— le sprite droit d'avant — quand rien n'est réglé.θ_écranest la tangente écran complète (direction monde tournée parrot()plus la dérivée de l'arc), sans quoi une flèche en cloche pointerait droit en montant et pointerait à côté dès qu'un changement d'acteur fait pivoter la caméra. ⚠️_placerTokenn'est pas réutilisable ici : elle annule toujours la rotation caméra, ce qu'il faut pour un portrait et jamais pour une flèche.
Son — son (fichier de templates/resources/sounds, mount /sounds) joué d'un point de départ à un point d'arrêt DANS le fichier (son_debut_ms/son_fin_ms ; ⚠️ son_fin_ms = 0 vaut « jusqu'au bout », pas « ne rien jouer » : la durée réelle n'est connue que du navigateur), plus son_volume borné [0,1]. Une planche sonore contient plusieurs bruits, comme une feuille contient plusieurs images.
- ⚠️ Une animation peut n'avoir AUCUNE image — la forme voulue pour
miss/fumble, qui n'ont rien à montrer. La règle de rejet est devenue « ni image NI son ⇒None». Côté client, le son part en premier et sans condition, puisjouerVfxsort sans rien peindre sifichierest vide. - ⚠️ Le son ne commande JAMAIS le tempo :
revealNextattendduree_ms(ou la durée du vol) et le son sonne librement par-dessus les lignes suivantes — un clang d'épée finit de résonner, il n'est ni coupé ni attendu. ⚠️prefers-reduced-motioncoupe le sprite, pas le son (on protège le mouvement, pas l'audio). - ⚠️ Trois pièges du lecteur (
jouerSon, un seul<audio id="vfx-audio">caché — une animation à la fois) :srcn'est réassigné que s'il change (sinon rechargement réseau à chaque coup) ;currentTimene se pose qu'aprèsloadedmetadata(plus tôt la valeur est ignorée et le son repart du début) ;play()REJETTE quand l'autoplay est bloqué (aucun geste utilisateur au premier rendu) ⇒.catch()obligatoire, un son qui ne part pas ne doit jamais interrompre la révélation. Coupe-son 🔊/🔇 au bandeau de tour (localStorage) — ⚠️ couper doit arrêter le son en cours, sinon le bouton paraît cassé.
Admin /admin/animations — cinq cartes. ▶ Scanner (POST /admin/animations/scan) : un doc par fichier NOUVEAU, feuilles d'effets ET sons, actif:false, n'écrase jamais un doc existant ⇒ idempotent et relançable. ⚠️ Deux ensembles « déjà couverts » DISTINCTS (fichier / son) : avec un seul, aucun son ne serait reconnu comme couvert et chaque scan recréerait la sonothèque. ⚠️ deviner_grille ne fait que semer une hypothèse (taille de frame annoncée dans le nom, nombre d'images, bande simple, planche carrée, sinon 1×1) : rien n'est jamais actif sans confirmation humaine. ⚠️ Import de Pillow PARESSEUX — tests/ importe utils/* → routers/*, Pillow n'est pas dans les dépendances de collecte locale. Éditeur : aperçu animé + feuille avec la grille de découpe superposée (c'est lui qui rend le réglage possible), durée réelle du son mesurée par un <audio> dédié, ⧉ Dupliquer = 2ᵉ animation d'une même feuille. Mini-scène de combat : 5×5 cases, principal et compagnon d'un côté, ennemi en face — offensif ⇒ sprite sur l'ennemi, défensif ⇒ sur un allié, ancrage:"acteur" ⇒ sur le lanceur (même arbitrage que jouerVfx), un depart_ancrage y fait voler le sprite ; _poserScene/_angleScene rejouent l'arithmétique de _poserVfx/_angleVfx sans caméra. C'est le seul aperçu qui montre la taille du sprite par rapport à un personnage. ⚠️ L'aperçu ne relance le son que s'il est terminé (2 s de son sur une boucle de 600 ms = mitraillette ; ▶ Jouer et ⟳ Boucler, gestes explicites, coupent d'abord). Liaisons (POST /admin/animations/lier = fusion mono-champ, à l'opposé du PUT complet d'admin_import_bulk) : ⚠️ le champ est un <input list=…> et non un <select> — un select ne se COLLE pas, et remplir vingt lignes passe par un copier-coller ; un id inconnu est signalé et rien n'est écrit. Rappel des variables de monde (animations.defauts_canaux()) — ⚠️ LECTURE SEULE, aucun writer : COMBAT_ANIMATIONS_DEFAUT s'édite dans /admin, deux écrans qui l'écriraient se contrediraient ; un défaut pointant vers un brouillon y est signalé en rouge (le catalogue l'exclut, donc il ne joue rien — le réglage a l'air fait et ne l'est pas). CRUD des docs : aucun endpoint neuf, PUT/DELETE /admin/doc sont génériques.
Lecture en combat : ANIMATIONS (catalogue actif, contexte /combat/{id}, décalages et durée de vol déjà effectifs) + jouerVfx(vfx) → Promise<durée> ; revealNext() est async et attend l'animation avant d'écrire sa ligne, au rythme max(450, duree) — chaque coup se voit puis se lit, donc une seule animation à la fois (choix retenu ; cf. § Révélation différée). Le sprite est un div.vfx-sprite créé dans #tokens-layer (jamais vidé), dimensionné en multiples de --step (hauteur au ratio d'une frame, pas de la feuille), placé par _poserVfx et défilé en requestAnimationFrame via background-position en pourcentages (garde à 0 si une seule colonne/ligne — division par zéro). prefers-reduced-motion ⇒ aucun sprite (le template porte sa propre garde, il n'inclut pas part-accessibility-css.html).
Révélation différée — l'ANIMATION d'abord, les conséquences ensuite
Le serveur résout tout d'un bloc (un tour de monstre entier arrive dans UNE réponse) : appliquer l'état final dès la réponse montrait le résultat avant le coup. L'ordre est désormais, par entrée de journal : le canal est déjà choisi par le serveur, l'animation joue, puis la ligne s'écrit et les jetons/barres bougent. Chokepoint combat._avec_etat (jumeau de _avec_vfx) photographie après mutation les seuls champs peints par le client (CHAMPS_ETAT) ; un acteur qu'aucune entrée ne nomme n'est jamais gelé (dégrade vers « immédiat », jamais vers « faux »). Couvert par tests/test_combat_journal_etat.py. ⚠️ Limite assumée : le chemin case par case n'est pas rejoué, le serveur n'écrivant qu'un log move agrégé par tour.
Client (combat_telluris.html) : updateUI lit l'état affiché, _gelerActeurs rembobine les champs des acteurs nommés par les nouvelles lignes et met la vérité de côté dans etatFinal (calculé avant enqueueLogs). revealNext fait jouerVfx → _ecrireLigne → _appliquerEtat → _rafraichirAffichage ; _finDeRevelation réconcilie depuis etatFinal, rend la main et déclenche l'overlay de fin. ⚠️ Les commandes sont VERROUILLÉES pendant la révélation (updateButtons/doAction refusent) — l'affichage retarde volontairement, une cible peinte debout peut être déjà morte côté serveur. Mais la file rend la main dès la dernière ligne révélée, sans attendre la pause de lecture. ⚠️ Le try/catch de revealNext est indispensable : une exception laisserait revealing à true, donc le jeu verrouillé pour de bon.
Simulateur de duel & potentiels (/admin/simulateur)
Banc d'essai d'équilibrage : deux belligérants (espèce ± profil, bornes min/max, ou un character), N passes de duel sur les vraies formules du moteur (utils/simulateur.py, toutes les résolutions passent par utils/combat), et trois potentiels (utils/potentiel.py, REGLES_POTENTIEL = le point d'édition des pondérations, combat = sqrt(offense × survie) contre un adversaire étalon). ⚠️ Rien n'est écrit en base, la partie des joueurs n'est jamais touchée. Le duel, les stats forcées (whitelist fail-closed STATS_FORCABLES, ré-appliquées après chaque refresh de snapshot) et l'équipement d'essai (paperdoll, espèces humanoïdes seulement) sont couverts par tests/test_simulateur.py et tests/test_simulateur_equipement.py ; la partie potentiel.py (scoring) n'a qu'une couverture partielle.
⚠️ Le facteur d'armure d'essai est un ContextVar (character_stats.facteur_degats_armure_simule), jamais la globale réassignée — qui changerait les dégâts de tous les joueurs pendant le run. Même mécanisme que RequestDocCacheMiddleware. ⚠️ Réglage d'essai ≠ règle du monde : ce qui altère les règles (facteur, stats forcées, équipement) n'est jamais mémorisé en localStorage et tout run altéré le dit dans ses résultats — un score truqué pris pour une mesure serait pire que pas de mesure.