Implemente le mini-jeu Memory (retournement de cartes, mode paire/single)

document_engine/labels/memory_config.py (nouveau) : modele de donnees,
meme convention resolve_X/sanitize_X que quiz_config.py/
association_config.py - DEFAULT_MEMORY_CONFIG, sanitize_memory_config.
Chaque carte a un recto ET un verso, chacun avec image/texte
independants et tous deux optionnels ; seul le verso doit avoir au
moins l'un des deux non vide (rien a reveler/apparier sinon) - le
recto peut rester entierement vide (dos de carte generique "?" par
defaut). Liste tronquee a MAX_CARDS=8.

Cote serveur, routes/document/document_element_update.py revalide
desormais aussi memory avant persistance. Le rendu (_render_memory)
affiche un resume reel (nombre de cartes, mode) et, des qu'au moins
une carte existe, un plateau de retournement REELEMENT interactif en
Mode Apercu (_render_memory_player) : en mode "paire", chaque carte
definie est DUPLIQUEE en deux instances partageant le meme card_index
(appariement classique) ; en mode "single", une seule instance par
carte (simple retournement, sans appariement - "c'est donc un
retourner de carte classique plus un jeu memory"). Les instances sont
melangees (random.shuffle, documente dans CODE_QUALITY.md) puis
embarquees en JSON dans data-memory-config.

Cote editeur, le panneau Proprietes propose un bascule segmentee
Paire/Simple et une liste de cartes repetable, chaque carte avec ses
deux faces (recto/verso) editables independamment (image + texte).
Extrait au passage forgeDocEscapeHtml (ex-forgeDocEscapeForTextarea,
generalise pour couvrir aussi les attributs) reutilise pour les deux
mini-jeux. Le plateau jouable (static/document/js/document-editor.js)
est une vraie carte-retournement CSS 3D (perspective/rotateY), contenu
de chaque face construit via DOM (textContent/img.src, jamais
innerHTML avec le texte du createur - meme precaution que le plateau
Association) : bon appariement verrouille en vert, mauvais reinitialise
apres un delai, ecran de resultat une fois le jeu termine (les deux
modes), et un "Recommencer" qui remelange reellement les cartes
(Fisher-Yates cote client).

Tests : 11 tests purs (tests/document/test_memory_config.py, sans
Flask, dont un qui verifie explicitement la duplication en mode paire
vs son absence en mode single) + 1 test de route verifiant la
sanitization a l'ecriture.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 62 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur carte invalide, rendu du plateau,
et simulation DOM du gameplay reel dans les DEUX modes (mode paire :
mauvais appariement puis bon appariement puis jeu complet ; mode
single : retournement puis jeu complet) - script de diagnostic non
conserve dans le depot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 14:13:28 +02:00
co-authored by Claude Sonnet 5
parent cf46da3388
commit 4111081e1a
12 changed files with 732 additions and 39 deletions
+296 -11
View File
@@ -477,6 +477,7 @@ function forgeDocBindCanvasInteractions() {
});
forgeDocInitQuizPlayers();
forgeDocInitAssociationPlayers();
forgeDocInitMemoryPlayers();
}
/* ---------------------------------------------------------------------
@@ -746,6 +747,174 @@ function forgeDocInitAssociationPlayers() {
});
}
/* ---------------------------------------------------------------------
* Memory — retournement de cartes RÉELLEMENT interactif en Mode Aperçu :
* mode "paire" (retrouver les deux cartes identiques, classique) ou
* "single" (retourner chaque carte une fois, sans appariement). Contenu
* de chaque face construit via DOM (textContent/img.src, jamais innerHTML
* avec le texte du créateur) — même précaution que le plateau Association.
* ------------------------------------------------------------------- */
function forgeDocMemoryPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.memoryConfig || '{}');
return {
mode: config.mode === 'single' ? 'single' : 'paire',
cards: Array.isArray(config.cards) ? config.cards : [],
};
} catch (e) {
return { mode: 'paire', cards: [] };
}
}
function forgeDocShuffleArray(array) {
const copy = [...array];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function forgeDocFillMemoryCardFace(container, face) {
container.innerHTML = '';
if (face.image) {
const img = document.createElement('img');
img.src = face.image;
img.alt = '';
container.appendChild(img);
}
if (face.text) {
const span = document.createElement('span');
span.textContent = face.text;
container.appendChild(span);
}
if (!face.image && !face.text) {
const glyph = document.createElement('span');
glyph.className = 'docMemoryCardBackGlyph';
glyph.textContent = '?';
container.appendChild(glyph);
}
}
function forgeDocBuildMemoryCard(cardData, index) {
const cardEl = document.createElement('div');
cardEl.className = 'docMemoryCard';
cardEl.dataset.instanceIndex = String(index);
cardEl.dataset.cardIndex = String(cardData.card_index);
const inner = document.createElement('div');
inner.className = 'docMemoryCardInner';
const front = document.createElement('div');
front.className = 'docMemoryCardFace docMemoryCardFront';
forgeDocFillMemoryCardFace(front, cardData.recto);
const back = document.createElement('div');
back.className = 'docMemoryCardFace docMemoryCardBack';
forgeDocFillMemoryCardFace(back, cardData.verso);
inner.appendChild(front);
inner.appendChild(back);
cardEl.appendChild(inner);
return cardEl;
}
function forgeDocMemoryUpdateProgress(playerEl, state) {
const progEl = playerEl.querySelector('.docMemoryProg');
progEl.textContent = state.mode === 'single'
? `${state.revealed} / ${state.total} cartes retournées`
: `${state.matched} / ${state.totalPairs} paires trouvées`;
}
function forgeDocMemoryCheckComplete(playerEl, state) {
const done = state.mode === 'single' ? state.revealed >= state.total : state.matched >= state.totalPairs;
if (!done) return;
playerEl.querySelector('.docMemoryCardWrap').style.display = 'none';
const resultCard = playerEl.querySelector('.docMemoryResultCard');
resultCard.style.display = 'block';
playerEl.querySelector('.docMemoryResultSub').textContent = state.mode === 'single'
? 'Toutes les cartes ont été retournées !'
: 'Toutes les paires ont été trouvées !';
}
function forgeDocMemoryFlip(playerEl, state, cardEl) {
if (cardEl.classList.contains('is-flipped') || cardEl.classList.contains('is-matched')) return;
if (state.mode === 'single') {
cardEl.classList.add('is-flipped', 'is-matched');
state.revealed += 1;
forgeDocMemoryUpdateProgress(playerEl, state);
forgeDocMemoryCheckComplete(playerEl, state);
return;
}
if (state.busy) return;
cardEl.classList.add('is-flipped');
state.flippedCards.push(cardEl);
if (state.flippedCards.length < 2) return;
state.busy = true;
const [first, second] = state.flippedCards;
const isMatch = first.dataset.cardIndex === second.dataset.cardIndex;
if (isMatch) {
first.classList.add('is-matched');
second.classList.add('is-matched');
state.matched += 1;
state.flippedCards = [];
state.busy = false;
forgeDocMemoryUpdateProgress(playerEl, state);
forgeDocMemoryCheckComplete(playerEl, state);
} else {
setTimeout(() => {
first.classList.remove('is-flipped');
second.classList.remove('is-flipped');
state.flippedCards = [];
state.busy = false;
}, 800);
}
}
function forgeDocMemoryRestart(playerEl) {
const data = forgeDocMemoryPlayerData(playerEl);
const shuffled = forgeDocShuffleArray(data.cards);
const totalPairs = new Set(data.cards.map((c) => c.card_index)).size;
const state = {
mode: data.mode,
total: data.cards.length,
totalPairs,
matched: 0,
revealed: 0,
flippedCards: [],
busy: false,
};
playerEl.querySelector('.docMemoryResultCard').style.display = 'none';
playerEl.querySelector('.docMemoryCardWrap').style.display = 'block';
playerEl.querySelector('.docMemoryTitle').textContent = state.mode === 'single'
? 'Retournez chaque carte'
: 'Retrouvez les paires';
forgeDocMemoryUpdateProgress(playerEl, state);
const grid = playerEl.querySelector('.docMemoryGrid');
grid.innerHTML = '';
shuffled.forEach((cardData, index) => {
const cardEl = forgeDocBuildMemoryCard(cardData, index);
cardEl.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMemoryFlip(playerEl, state, cardEl);
});
grid.appendChild(cardEl);
});
}
function forgeDocInitMemoryPlayers() {
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
const data = forgeDocMemoryPlayerData(playerEl);
if (!data.cards.length) return;
forgeDocMemoryRestart(playerEl);
playerEl.querySelector('.docMemoryRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMemoryRestart(playerEl);
});
});
}
function forgeDocSetActiveBottomNav(target) {
document.querySelectorAll('#docBottomNav button').forEach((b) => {
b.classList.toggle('is-active', b.dataset.target === target);
@@ -1084,15 +1253,20 @@ function forgeDocAssociationNewPair() {
return { left: 'Nouvel élément', right: 'Sa correspondance' };
}
function forgeDocEscapeForTextarea(text) {
// Les deux côtés d'une paire sont du texte libre du créateur, inséré ici
// comme CONTENU d'un <textarea> dans un template string (jamais via
// .value, voir forgeDocRenderAssociationProps qui construit tout le
// panneau en une seule chaîne) — sans cet échappement, un texte
// contenant littéralement "</textarea>" romprait le tag et tout ce qui
// suit serait interprété comme du HTML/JS brut dans SA PROPRE session
// d'édition.
return String(text).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
function forgeDocEscapeHtml(text) {
// Texte libre du créateur (Association/Memory), inséré comme CONTENU
// d'un <textarea> ou comme VALEUR d'attribut dans un template string
// (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
// forgeDocRenderMemoryProps qui construisent tout le panneau en une
// seule chaîne) — sans cet échappement, un texte contenant littéralement
// "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
// qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
// session d'édition.
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
@@ -1106,13 +1280,13 @@ function forgeDocRenderAssociationPairHtml(pair, pIndex) {
rows="2"
placeholder="Élément"
data-pair-index="${pIndex}"
data-side="left">${forgeDocEscapeForTextarea(pair.left)}</textarea>
data-side="left">${forgeDocEscapeHtml(pair.left)}</textarea>
<div class="docAssocPairLink" aria-hidden="true">↕</div>
<textarea class="docAssocPairInput"
rows="2"
placeholder="Correspondance"
data-pair-index="${pIndex}"
data-side="right">${forgeDocEscapeForTextarea(pair.right)}</textarea>
data-side="right">${forgeDocEscapeHtml(pair.right)}</textarea>
</div>
`;
}
@@ -1165,6 +1339,116 @@ function forgeDocRenderAssociationProps(panel, el) {
});
}
function forgeDocMemoryNewCard() {
// Verso pré-rempli (jamais vide) : sanitize_memory_config
// (routes/document/document_element_update.py) rejette silencieusement
// toute carte dont le verso est entièrement vide (rien à révéler) —
// même leçon que forgeDocAssociationNewPair/forgeDocQuizNewQuestion
// ci-dessus (voir leur commentaire : sans ça, "+ Ajouter une carte"
// semblerait ne rien faire).
return { recto: { image: '', text: '' }, verso: { image: '', text: 'Nouvelle carte' } };
}
function forgeDocRenderMemoryCardHtml(card, cIndex) {
return `
<div class="docAssocPairRow" data-card-index="${cIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Carte ${cIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-card-index="${cIndex}" title="Supprimer cette carte">🗑</button>
</div>
<span class="docMemoryFaceLabel">Recto (face cachée, optionnel)</span>
<input type="text"
class="docAssocPairInput"
placeholder="URL de l'image"
value="${forgeDocEscapeHtml(card.recto.image)}"
data-card-index="${cIndex}"
data-face="recto"
data-field="image">
<textarea class="docAssocPairInput"
rows="2"
placeholder="Texte"
data-card-index="${cIndex}"
data-face="recto"
data-field="text">${forgeDocEscapeHtml(card.recto.text)}</textarea>
<span class="docMemoryFaceLabel">Verso (révélé au retournement)</span>
<input type="text"
class="docAssocPairInput"
placeholder="URL de l'image"
value="${forgeDocEscapeHtml(card.verso.image)}"
data-card-index="${cIndex}"
data-face="verso"
data-field="image">
<textarea class="docAssocPairInput"
rows="2"
placeholder="Texte"
data-card-index="${cIndex}"
data-face="verso"
data-field="text">${forgeDocEscapeHtml(card.verso.text)}</textarea>
</div>
`;
}
function forgeDocRenderMemoryProps(panel, el) {
const a = el.attributes;
const cards = a.cards || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('memory')}
<div class="docField">
<span class="docFieldLabel">Mode</span>
<div class="docSegmented" id="docMemoryMode">
<button type="button" class="docSegBtn ${a.mode === 'paire' ? 'is-active' : ''}" data-mode="paire">Paire (dupliquer)</button>
<button type="button" class="docSegBtn ${a.mode === 'single' ? 'is-active' : ''}" data-mode="single">Simple (sans doublon)</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMemoryColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Cartes</div>
<div id="docMemoryCardsList">${cards.length ? cards.map(forgeDocRenderMemoryCardHtml).join('') : '<div class="docEmptyState">Aucune carte — ajoute la première ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMemoryAddCard">+ Ajouter une carte</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMemoryMode .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ mode: btn.dataset.mode }));
});
panel.querySelectorAll('#docMemoryColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docMemoryAddCard').addEventListener('click', () => {
patch({ cards: [...cards, forgeDocMemoryNewCard()] });
});
panel.querySelectorAll('#docMemoryCardsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.cardIndex);
patch({ cards: cards.filter((_, i) => i !== cIndex) });
});
});
panel.querySelectorAll('#docMemoryCardsList .docAssocPairInput').forEach((input) => {
input.addEventListener('change', (e) => {
const cIndex = Number(input.dataset.cardIndex);
const { face, field } = input.dataset;
patch({
cards: cards.map((c, i) => (i === cIndex ? { ...c, [face]: { ...c[face], [field]: e.target.value } } : c)),
});
});
});
}
function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel');
if (!el) {
@@ -1178,6 +1462,7 @@ function forgeDocRenderProps(el) {
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
else forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn');