Enigma system¶
This page describes the validation mechanics of enigmas: the
Java-side data structure, the associated funnel actions, answer
normalization, SHA-256 hashing and the handling of the special
DYNAMIC case. This page is a technical reference — it contains
no enigma answers.
Anatomy of an enigma¶
Each enigma is an immutable Java record defined in
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
) {}
| Field | Type | Role |
|---|---|---|
id |
String |
Unique identifier, format BAC{n}-{step} (e.g., BAC3-17) |
level |
String |
Parent level: "bac1" … "bac5" |
step |
int |
Step number in the level (1-based) |
text |
String |
Statement resolved via t(level, "<id>.text") from the i18n JSON |
position |
String |
Overlay positioning on screen: top, bottom, left, right |
hint |
String |
Text hint, resolved via t(level, "<id>.hint") |
hintAvailable |
boolean |
If true, the student can display the hint via the 💡 button (provided the instructor has not globally disabled hints — see hints.md) |
answerHash |
String |
Hexadecimal SHA-256 of the expected answer, or the sentinel string "DYNAMIC" (special case) |
culturalNote |
String |
Optional cultural note displayed after a correct answer (history, anecdote, perspective) |
action |
String |
Expected action in the e-commerce funnel (see below) |
The record is immutable: it is assembled once during the static
initialization of PedagogiqueEnigme by invoking the register(Map)
methods of the five companion classes PedagogiqueEnigmeBacN. The
resulting map is wrapped in Collections.unmodifiableMap() and
exposed via the public API:
public static final Map<String, Enigme> ALL; // key = id
public static List<Enigme> forLevel(String level); // sorted by step
public static int defaultTimerSeconds(String level);
The ten funnel actions¶
The action field ties each enigma to a real action of the
e-commerce funnel, which anchors the pedagogy in a concrete gesture.
These values are purely indicative frontend-side — the backend does
not verify that the action was performed, it only validates the
answer hash. The goal is to let the instructor explain why the
student must go to this or that part of the store.
| Action | i18n label | Where in the store |
|---|---|---|
filter |
ped.action.filter |
Catalog filter bar (min/max price, category) |
search |
ped.action.search |
Search bar at the top of the catalog |
navigate |
ped.action.navigate |
Click on a product page or a navigation link |
input |
ped.action.input |
Direct entry in the overlay's answer field |
count |
ped.action.count |
Count displayed elements |
add_to_cart |
ped.action.add_to_cart |
Add to cart from the product page |
checkout |
ped.action.checkout |
Move from cart to the checkout page |
fill_address |
ped.action.fill_address |
Filling in the shipping address |
fill_payment |
ped.action.fill_payment |
Entering payment information (simulated) |
confirm_order |
ped.action.confirm_order |
Final order confirmation click |
The labels are resolved frontend-side in PedagogiqueOverlay.jsx via
the ACTION_LABEL_KEYS mapping and displayed as a badge next to the
step counter (Step 7 / 20). When the action is null or unknown,
the badge is simply not displayed.
Overlay positioning¶
The position field indicates in which corner of the screen the
floating window should initially appear. The PedagogiqueOverlay
component transforms these values into absolute CSS position:
| Value | Initial position on screen |
|---|---|
top |
Horizontally centered, 80 px from the top |
bottom |
Horizontally centered, 24 px from the bottom |
left |
Vertically centered, 16 px from the left edge |
right |
Vertically centered, 16 px from the right edge |
These positions are non-blocking: the student can click the ⠿ title bar at the top of the overlay to drag it freely across the screen. An anti-overflow system prevents the window from leaving the visible viewport. The alternation of positions between consecutive steps is chosen by the designer to force the student to free a specific area of the page (e.g., leaving the product page visible for consultation).
Loading from i18n JSON files¶
The three text fields text, hint and culturalNote are not
hard-coded in Java; they are resolved at startup by the
PedagogiqueEnigme.t(level, key) method. The mechanism is as
follows:
flowchart TB
INIT["Spring Boot startup<br/>static init<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
The PERFSHOP_LANG environment variable (default fr) is read once
at class loading. Translations are cached in two static HashMaps:
TRANSLATIONS for the active language and FALLBACK_FR for French
(only if the active language is not fr). The resolution mechanism
is three-level:
- Look up the key in the active language
- Otherwise, look up in the French fallback
- Otherwise, return
[KEY](visible on screen — signals a missing key)
The JSON parser used is minimalist (parseSimpleJson method): it
only handles flat objects {"key": "value", …}, ignores keys
starting with _ (comments) and supports classic escapes (\n,
\t, \", \\, \uXXXX). Zero external dependency — a deliberate
choice for such a simple format.
For more details on the expected format, translation rules and
adding a new language, see i18n/enigmes.md.
Normalization and SHA-256 hashing¶
Answer validation relies on a hash ↔ hash comparison: the correct
answer is never sent in clear to the student, and the backend never
stores the answer in clear after startup (PedagogiqueEnigme.h() is
called once per step to pre-compute answerHash).
The normalization before hashing is identical on the Java and JavaScript sides to guarantee compatibility:
// PedagogiqueEnigme.java — h() method
String normalized = answer.trim().toLowerCase().replaceAll("\\s+", "");
// usePedagogiqueState.js — sha256hex() function
const normalized = answer.trim().toLowerCase().replace(/\s+/g, '');
The three operations are applied in order:
trim()— removal of leading and trailing whitespacetoLowerCase()— switch to lowercasereplaceAll('\s+', '')— removal of all internal whitespace
Practical consequence: " Nginx ", "nginx", "NGINX" and
"Ng inx" produce the same hash. Enigma designers exploit this
tolerance to accept variations of case and spacing, but must also
take it into account — an answer like "nginxproxy" would be hashed
identically to an input of "Nginx Proxy".
Backend implementation — 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 — non-compliant JRE", e);
}
}
The method is package-private — only the PedagogiqueEnigmeBacN
classes in the same package invoke it via
import static PedagogiqueEnigme.h;.
Frontend implementation — sha256hex()¶
On the browser side, the usePedagogiqueState hook exposes two
implementations with automatic fallback:
- Web Crypto API (
crypto.subtle.digest('SHA-256', …)) — used first when the page is served over HTTPS or onlocalhost. - Pure JavaScript SHA-256 (
_sha256pure()) — fallback for deployments on a local IP over plain HTTP (addresses192.168.x.x,10.x.x.x). The implementation follows FIPS 180-4 and produces a byte-for-byte identical result tocrypto.subtle.
The pure JS fallback is essential for classroom lab sessions
where the infrastructure is behind a local IP and has no TLS
certificate: crypto.subtle is then undefined in Chromium browsers
for security reasons (non-secure context).
Step validation sequence¶
sequenceDiagram
autonumber
actor E as Student
participant OVR as PedagogiqueOverlay
participant API as sha256hex()
participant CTR as ChaosStudentController
participant SVC as PedagogiqueSessionService
participant DB as MySQL
E->>OVR: Types "nginx" in the field<br/>then clicks "Validate"
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: Otherwise → 409<br/>{expected, submitted}
CTR->>CTR: Enigme = enigmes.get(stepIndex)
alt answerHash == "DYNAMIC"
CTR->>CTR: expectedHash = session.extractionAnswerHash
else static hash
CTR->>CTR: expectedHash = enigme.answerHash()
end
CTR->>CTR: attempts++ (atomic)
CTR->>SVC: saveSession(session, level)
SVC->>DB: UPDATE pedagogique_sessions
alt hash match
CTR->>CTR: session.step.incrementAndGet()
alt last step
CTR->>CTR: completedAt = now()
end
CTR-->>OVR: {valid:true, completed?, nextEnigme?, culturalNote?, stars?}
else hash mismatch
CTR-->>OVR: {valid:false, attempts}
end
OVR->>E: Displays green/red feedback
Key points on this sequence:
attemptsis a per-step counter: the backend persists each attempt (correct or not) in thesession.attemptsmap serialized as JSON in theattempts_jsoncolumn. This feeds the admin statistics.- Order is strict: if the student sends
step=5while the session is atstep=3, the backend respondsHTTP 409with{expected: 3, submitted: 5}. There is no possible step skip. - The token is mandatory: without
X-Student-Token, the request returnsHTTP 401with an i18n error message (student.pedagogique.error.token_missing). - A cultural note triggers an animation: if the enigma defines
culturalNote, the response includes this text and the overlay enables askipPollingRefflag to freeze polling for 1.5 s - 15 ms per character of the note, guaranteeing a minimum reading time.
The special DYNAMIC case¶
The last five steps (one per level: BAC1-10, BAC2-15,
BAC3-20, BAC4-25, BAC5-30) have answerHash = "DYNAMIC" in
their Java declaration. This marker is a sentinel meaning: the
correct answer depends on the student's agent code and cannot be
pre-computed at catalog load.
At the time of POST /pedagogique/join, the service computes:
String agentCode = /* 4 digits according to the level */;
String dynamicAnswer = computeDynamicAnswer(agentCode, level);
session.agentCode = agentCode;
session.extractionAnswerHash = sha256(dynamicAnswer);
The resulting hash is stored in
MutableSession.extractionAnswerHash and persisted to the database
(extraction_answer_hash). During the validation of the final step,
the controller substitutes this hash for the "DYNAMIC" sentinel:
String expectedHash = "DYNAMIC".equals(enigme.answerHash())
? (session.extractionAnswerHash != null ? session.extractionAnswerHash : "")
: enigme.answerHash();
The student reads their agent code on the order confirmation page
(OrderConfirmation.jsx component) after validating the commercial
steps of the funnel, then applies the formula of the corresponding
level to compute the numeric answer — see agent-code.md
for the details of the five formulas and non-spoiler examples.
Why DYNAMIC is essential
If the final step had a static hash, all students would share
the same answer and a first student who finished could whisper
it to the others. DYNAMIC guarantees that each student must do
the computation themselves with their unique agent code.
Security-wise, the correct answer never travels over the network
— only the hash is compared.
Hydration from the database¶
On every backend restart, the catalog is rebuilt identically (same
ids, same hashes — the answers are in the source code). Active
sessions, however, are reloaded from the database via
DefaultPedagogiqueSessionService.toMutableSession() which restores
all fields, including extractionAnswerHash and the logic indices.
A journey in progress at the time of a restart therefore resumes
exactly where it was: same agentCode, same dynamic hash, same
number of attempts per step.
Next pages: ← Levels · Dynamic agent code → · Star system →