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<String,String>"]
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 :
- Chercher la clé dans la langue active
- Sinon, chercher dans le fallback français
- 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 :
trim()— suppression des espaces de début et de fintoLowerCase()— bascule en minusculesreplaceAll('\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 :
- Web Crypto API (
crypto.subtle.digest('SHA-256', …)) — utilisée en priorité quand la page est servie en HTTPS ou surlocalhost. - SHA-256 pur JavaScript (
_sha256pure()) — fallback pour les déploiements sur IP locale en HTTP simple (adresses192.168.x.x,10.x.x.x). L'implémentation suit FIPS 180-4 et produit un résultat identique octet pour octet à celui decrypto.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 :
attemptsest un compteur par étape : le backend persiste chaque tentative (correcte ou non) dans la mapsession.attemptssérialisée au format JSON dans la colonneattempts_json. Ceci alimente les statistiques admin.- L'ordre est strict : si l'étudiant envoie
step=5alors que la session est àstep=3, le backend répondHTTP 409avec{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 retourneHTTP 401avec 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 flagskipPollingRefpour 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 →