Skip to content

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&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

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:

  1. Look up the key in the active language
  2. Otherwise, look up in the French fallback
  3. 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:

  1. trim() — removal of leading and trailing whitespace
  2. toLowerCase() — switch to lowercase
  3. replaceAll('\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:

  1. Web Crypto API (crypto.subtle.digest('SHA-256', …)) — used first when the page is served over HTTPS or on localhost.
  2. Pure JavaScript SHA-256 (_sha256pure()) — fallback for deployments on a local IP over plain HTTP (addresses 192.168.x.x, 10.x.x.x). The implementation follows FIPS 180-4 and produces a byte-for-byte identical result to crypto.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:

  • attempts is a per-step counter: the backend persists each attempt (correct or not) in the session.attempts map serialized as JSON in the attempts_json column. This feeds the admin statistics.
  • Order is strict: if the student sends step=5 while the session is at step=3, the backend responds HTTP 409 with {expected: 3, submitted: 5}. There is no possible step skip.
  • The token is mandatory: without X-Student-Token, the request returns HTTP 401 with 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 a skipPollingRef flag 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 →