Aller au contenu

Système d'énigmes

Cette page décrit la mécanique de validation des énigmes : la structure de données côté Java, les actions de tunnel associées, la normalisation des réponses, le hachage SHA-256 et le traitement du cas spécial DYNAMIC. Cette page est une référence technique — elle ne contient aucune réponse d'énigme.

Anatomie d'une énigme

Chaque énigme est un record Java immuable défini dans PedagogiqueEnigme.java :

public record Enigme(
    String  id,
    String  level,
    int     step,
    String  text,
    String  position,
    String  hint,
    boolean hintAvailable,
    String  answerHash,
    String  culturalNote,
    String  action
) {}
Champ Type Rôle
id String Identifiant unique, format BAC{n}-{step} (ex : BAC3-17)
level String Niveau parent : "bac1""bac5"
step int Numéro d'étape dans le niveau (1-based)
text String Énoncé résolu via t(level, "<id>.text") depuis le JSON i18n
position String Positionnement de l'overlay à l'écran : top, bottom, left, right
hint String Indice textuel, résolu via t(level, "<id>.hint")
hintAvailable boolean Si true, l'étudiant peut afficher l'indice via le bouton 💡 (sous réserve que le formateur n'ait pas désactivé les indices globalement — voir hints.md)
answerHash String SHA-256 hexadécimal de la réponse attendue, ou la chaîne sentinelle "DYNAMIC" (cas spécial)
culturalNote String Note culturelle optionnelle affichée après une bonne réponse (historique, anecdote, mise en perspective)
action String Action attendue dans le tunnel e-commerce (voir ci-dessous)

Le record est immuable : il est assemblé une seule fois à l'initialisation statique de PedagogiqueEnigme en invoquant les méthodes register(Map) des cinq classes compagnes PedagogiqueEnigmeBacN. La map résultante est encapsulée dans Collections.unmodifiableMap() et exposée via l'API publique :

public static final Map<String, Enigme> ALL;                 // clé = id
public static List<Enigme> forLevel(String level);           // tri par step
public static int defaultTimerSeconds(String level);

Les dix actions du tunnel

Le champ action rattache chaque énigme à une action réelle du tunnel e-commerce, ce qui permet à la pédagogie d'être ancrée dans un geste concret. Ces valeurs sont purement indicatives côté frontend — le backend ne vérifie pas que l'action a été effectuée, il ne fait que valider le hash de la réponse. L'objectif est que le formateur puisse expliquer pourquoi l'étudiant doit aller à tel endroit de la boutique.

Action Libellé i18n Où dans la boutique
filter ped.action.filter Barre de filtres du catalogue (prix min/max, catégorie)
search ped.action.search Barre de recherche en haut du catalogue
navigate ped.action.navigate Clic sur une fiche produit ou sur un lien de navigation
input ped.action.input Saisie directe dans le champ réponse de l'overlay
count ped.action.count Compter des éléments affichés
add_to_cart ped.action.add_to_cart Ajout au panier depuis la fiche produit
checkout ped.action.checkout Passage du panier à la page de checkout
fill_address ped.action.fill_address Remplissage de l'adresse de livraison
fill_payment ped.action.fill_payment Saisie des informations de paiement (simulées)
confirm_order ped.action.confirm_order Clic final de confirmation de commande

Les libellés sont résolus côté frontend dans PedagogiqueOverlay.jsx via le mapping ACTION_LABEL_KEYS et affichés en badge à côté du compteur d'étape (Étape 7 / 20). Quand l'action est null ou inconnue, le badge n'est tout simplement pas affiché.

Positionnement de l'overlay

Le champ position indique dans quel coin de l'écran la fenêtre flottante doit apparaître initialement. Le composant PedagogiqueOverlay transforme ces valeurs en position CSS absolue :

Valeur Position initiale à l'écran
top Centré horizontalement, 80 px du haut
bottom Centré horizontalement, 24 px du bas
left Centré verticalement, 16 px du bord gauche
right Centré verticalement, 16 px du bord droit

Ces positions sont non bloquantes : l'étudiant peut cliquer sur la barre de titre ⠿ en haut de l'overlay pour le déplacer librement à l'écran avec la souris. Un système d'anti-débordement empêche la fenêtre de sortir du viewport visible. L'alternance des positions entre étapes consécutives est choisie par le concepteur pour forcer l'étudiant à libérer une zone précise de la page (ex : laisser la fiche produit visible pour la consulter).

Chargement depuis les fichiers JSON i18n

Les trois champs textuels text, hint et culturalNote ne sont pas codés en dur en Java ; ils sont résolus au démarrage par la méthode PedagogiqueEnigme.t(level, key). Le mécanisme est le suivant :

flowchart TB
    INIT["Démarrage Spring Boot<br/>init statique<br/>PedagogiqueEnigme"]
    BAC1["PedagogiqueEnigmeBac1.register()"]
    LOAD["loadTranslations('bac1')"]
    JSON["i18n/enigmes/bac1/<br/>enigmes_$LANG.json"]
    PARSE["parseSimpleJson()<br/>→ Map&lt;String,String&gt;"]
    CACHE["TRANSLATIONS cache<br/>+ FALLBACK_FR"]
    REC["new Enigme(<br/>id, level, step,<br/>t('BAC1-1.text'),<br/>position, ...)"]

    INIT --> BAC1 --> LOAD --> JSON --> PARSE --> CACHE
    CACHE --> REC

La variable d'environnement PERFSHOP_LANG (défaut fr) est lue une seule fois au chargement de la classe. Les traductions sont mises en cache dans deux HashMap statiques : TRANSLATIONS pour la langue active et FALLBACK_FR pour le français (seulement si la langue active n'est pas fr). Le mécanisme de résolution est à trois niveaux :

  1. Chercher la clé dans la langue active
  2. Sinon, chercher dans le fallback français
  3. Sinon, retourner [KEY] (visible à l'écran — signale une clé manquante)

Le parseur JSON utilisé est minimaliste (méthode parseSimpleJson) : il ne gère que les objets plats {"clé": "valeur", …}, ignore les clés commençant par _ (commentaires) et supporte les échappements classiques (\n, \t, \", \\, \uXXXX). Zéro dépendance externe — choix délibéré pour un format aussi simple.

Pour plus de détails sur le format attendu, les règles de traduction et l'ajout d'une nouvelle langue, voir i18n/enigmes.md.

Normalisation et hachage SHA-256

La validation des réponses repose sur une comparaison hash ↔ hash : jamais la bonne réponse n'est envoyée en clair à l'étudiant, et jamais le backend ne stocke la réponse en clair après le démarrage (PedagogiqueEnigme.h() est appelé une seule fois par étape pour précalculer answerHash).

La normalisation avant hachage est identique côté Java et côté JavaScript pour garantir la compatibilité :

// PedagogiqueEnigme.java — méthode h()
String normalized = answer.trim().toLowerCase().replaceAll("\\s+", "");
// usePedagogiqueState.js — fonction sha256hex()
const normalized = answer.trim().toLowerCase().replace(/\s+/g, '');

Les trois opérations sont appliquées dans l'ordre :

  1. trim() — suppression des espaces de début et de fin
  2. toLowerCase() — bascule en minuscules
  3. replaceAll('\s+', '') — suppression de tous les espaces internes

Conséquence pratique : " Nginx ", "nginx", "NGINX" et "Ng inx" produisent le même hash. Les concepteurs d'énigmes exploitent cette tolérance pour accepter les variations de casse et d'espacement, mais doivent aussi en tenir compte — une réponse comme "nginxproxy" serait hashée à l'identique d'une saisie "Nginx Proxy".

Implémentation backend — h()

static String h(String answer) {
    String normalized = answer.trim().toLowerCase().replaceAll("\\s+", "");
    try {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] bytes = md.digest(normalized.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder(64);
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException("SHA-256 unavailable — JRE non conforme", e);
    }
}

La méthode est package-private — seules les classes PedagogiqueEnigmeBacN du même paquet l'invoquent via import static PedagogiqueEnigme.h;.

Implémentation frontend — sha256hex()

Côté navigateur, le hook usePedagogiqueState expose deux implémentations avec fallback automatique :

  1. Web Crypto API (crypto.subtle.digest('SHA-256', …)) — utilisée en priorité quand la page est servie en HTTPS ou sur localhost.
  2. SHA-256 pur JavaScript (_sha256pure()) — fallback pour les déploiements sur IP locale en HTTP simple (adresses 192.168.x.x, 10.x.x.x). L'implémentation suit FIPS 180-4 et produit un résultat identique octet pour octet à celui de crypto.subtle.

Le fallback pur JS est essentiel pour les sessions de TP en salle de classe où l'infrastructure est derrière une IP locale et n'a pas de certificat TLS : crypto.subtle est alors undefined dans les navigateurs Chromium pour des raisons de sécurité (contexte non sécurisé).

Séquence de validation d'une étape

sequenceDiagram
    autonumber
    actor E as Étudiant
    participant OVR as PedagogiqueOverlay
    participant API as sha256hex()
    participant CTR as ChaosStudentController
    participant SVC as PedagogiqueSessionService
    participant DB as MySQL

    E->>OVR: Tape "nginx" dans le champ<br/>puis clique "Valider"
    OVR->>API: sha256hex("nginx")
    API-->>OVR: "c9765...f2e3"

    OVR->>CTR: POST /pedagogique/validate<br/>header X-Student-Token<br/>{step:2, answerHash:"c9765...f2e3"}

    CTR->>CTR: pedagogiqueActive? level?
    CTR->>SVC: getSession(token)
    SVC-->>CTR: MutableSession

    CTR->>CTR: stepIndex = step - 1
    CTR->>CTR: stepIndex == session.step.get() ?
    Note over CTR: Sinon → 409<br/>{expected, submitted}

    CTR->>CTR: Enigme = enigmes.get(stepIndex)

    alt answerHash == "DYNAMIC"
      CTR->>CTR: expectedHash = session.extractionAnswerHash
    else hash statique
      CTR->>CTR: expectedHash = enigme.answerHash()
    end

    CTR->>CTR: attempts++ (atomique)
    CTR->>SVC: saveSession(session, level)
    SVC->>DB: UPDATE pedagogique_sessions

    alt hash match
      CTR->>CTR: session.step.incrementAndGet()
      alt dernière étape
        CTR->>CTR: completedAt = now()
      end
      CTR-->>OVR: {valid:true, completed?, nextEnigme?, culturalNote?, stars?}
    else hash mismatch
      CTR-->>OVR: {valid:false, attempts}
    end

    OVR->>E: Affiche feedback vert/rouge

Points importants sur cette séquence :

  • attempts est un compteur par étape : le backend persiste chaque tentative (correcte ou non) dans la map session.attempts sérialisée au format JSON dans la colonne attempts_json. Ceci alimente les statistiques admin.
  • L'ordre est strict : si l'étudiant envoie step=5 alors que la session est à step=3, le backend répond HTTP 409 avec {expected: 3, submitted: 5}. Il n'y a pas de saut d'étape possible.
  • Le token est obligatoire : sans X-Student-Token, la requête retourne HTTP 401 avec un message d'erreur i18n (student.pedagogique.error.token_missing).
  • Une note culturelle déclenche une animation : si l'énigme définit culturalNote, la réponse inclut ce texte et l'overlay active un flag skipPollingRef pour figer le polling pendant 1,5 s
  • 15 ms par caractère de la note, garantissant un temps de lecture minimal.

Le cas spécial DYNAMIC

Les cinq dernières étapes (une par niveau : BAC1-10, BAC2-15, BAC3-20, BAC4-25, BAC5-30) ont answerHash = "DYNAMIC" dans leur déclaration Java. Ce marqueur est une sentinelle qui signifie : la bonne réponse dépend du code agent de l'étudiant et ne peut pas être précalculée au chargement du catalogue.

Au moment du POST /pedagogique/join, le service calcule :

String agentCode             = /* 4 digits selon le niveau */;
String dynamicAnswer         = computeDynamicAnswer(agentCode, level);
session.agentCode            = agentCode;
session.extractionAnswerHash = sha256(dynamicAnswer);

Le hash résultant est stocké dans MutableSession.extractionAnswerHash et persisté en base (extraction_answer_hash). Lors de la validation de l'étape finale, le contrôleur substitue ce hash au "DYNAMIC" sentinel :

String expectedHash = "DYNAMIC".equals(enigme.answerHash())
    ? (session.extractionAnswerHash != null ? session.extractionAnswerHash : "")
    : enigme.answerHash();

L'étudiant lit son code agent sur la page de confirmation de commande (composant OrderConfirmation.jsx) après avoir validé les étapes commerciales du tunnel, puis applique la formule du niveau correspondant pour calculer la réponse numérique — voir agent-code.md pour le détail des cinq formules et des exemples non-spoiler.

Pourquoi DYNAMIC est indispensable

Si l'étape finale avait un hash statique, tous les étudiants partageraient la même réponse et un premier étudiant ayant terminé pourrait la chuchoter aux autres. DYNAMIC garantit que chaque étudiant doit faire le calcul lui-même avec son code agent unique. Côté sécurité, la bonne réponse ne transite jamais sur le réseau — seul le hash est comparé.

Hydratation depuis la base

À chaque redémarrage du backend, le catalogue est rebuildé à l'identique (mêmes ids, mêmes hashes — les réponses sont dans le code source). Les sessions actives, elles, sont rechargées depuis la base via DefaultPedagogiqueSessionService.toMutableSession() qui restitue tous les champs, y compris extractionAnswerHash et les indices logique. Un parcours en cours au moment d'un redémarrage reprend donc exactement où il en était : même agentCode, même hash dynamique, même nombre de tentatives par étape.


Pages suivantes : ← Niveaux · Code agent dynamique → · Système d'étoiles →