Jouer et résoudre
Ecrit au départ en ABAP sous SAP, Migré à l’aide de Claude en Python puis HTML5
Test 2 sa mère !
<!DOCTYPE html>
<html lang=”fr”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no”>
<title>Le Compte Est Bon</title>
<meta name=”theme-color” content=”#1e3a52″>
<meta name=”apple-mobile-web-app-capable” content=”yes”>
<meta name=”apple-mobile-web-app-status-bar-style” content=”black-translucent”>
<meta name=”apple-mobile-web-app-title” content=”CEB”>
<link rel=”manifest” id=”pwa-manifest”>
<style>
:root {
–bg: #3a6186;
–bg-card: #4a7399;
–bg-hdr: #1e3a52;
–bg-sub: #2e5070;
–accent: #e05c6e;
–accent2: #f5a623;
–green: #2ecc71;
–blue: #5dade2;
–grey: #5a7a8a;
–text: #ffffff;
–text-med: #c8dde8;
–text-dim: #8ab0c4;
–border: #2e5070;
–yellow: #f5c518;
–btn-num: #1e3a52;
–btn-res: #2ecc71;
–btn-used: #2e5070;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
button, input, select { touch-action: manipulation; }
body { font-family: ‘Segoe UI’, system-ui, sans-serif; background: var(–bg); color: var(–text); min-height: 100vh; }
/* HEADER */
header { background: var(–bg-hdr); display: flex; align-items: center; justify-content: space-between; padding: 10px 20px; }
header h1 { font-size: 1.2rem; font-weight: 700; letter-spacing: 2px; }
header .hdr-right { display: flex; gap: 8px; }
/* INFO BAR */
.info-bar { background: var(–bg-sub); display: flex; align-items: center; gap: 20px; padding: 6px 20px; border-top: 1px solid var(–border); border-bottom: 1px solid var(–border); font-size: 0.85rem; }
.info-bar .timer { font-size: 1.1rem; font-weight: 700; color: var(–accent2); min-width: 60px; }
.info-bar .score { margin-left: auto; font-weight: 700; }
/* MAIN */
main { max-width: 680px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
/* CIBLE */
.cible-wrap { text-align: center; }
.cible-label { font-size: 0.85rem; color: var(–text-med); }
.cible-val { font-size: 3rem; font-weight: 700; color: var(–accent2); line-height: 1.1; }
/* PLAQUES */
.plq-section { }
.plq-title { font-size: 0.8rem; color: var(–text-dim); margin-bottom: 6px; }
.plq-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 8px;
}
.plq-btn-ghost {
background: transparent;
border: 2px dashed var(–border);
border-radius: 8px;
height: 52px;
opacity: .4;
pointer-events: none;
}
.plq-btn {
font-family: inherit; font-size: 1.1rem; font-weight: 700;
background: var(–btn-num); color: var(–text);
border: none; border-radius: 8px;
height: 52px; width: 100%; cursor: pointer;
transition: transform .1s, background .15s;
}
.plq-btn:hover:not(:disabled) { background: #2a5070; transform: scale(1.04); }
.plq-btn.selected { outline: 3px solid var(–yellow); }
.plq-btn.used { background: var(–btn-used); color: var(–text-dim); cursor: default; }
.plq-btn.result { background: var(–btn-res); color: #1a1a2e; }
.plq-btn:disabled { opacity: .5; cursor: default; }
/* CALCUL EN COURS */
.cur-op { background: var(–bg-card); border-radius: 8px; padding: 10px 16px; min-height: 44px; font-size: 1.1rem; font-weight: 700; color: var(–accent2); text-align: center; }
/* OPERATEURS */
.op-row { display: flex; justify-content: center; gap: 10px; }
.op-btn {
font-family: inherit; font-size: 1.2rem; font-weight: 700;
background: var(–accent); color: #fff;
border: none; border-radius: 8px;
width: 52px; height: 44px; cursor: pointer;
transition: transform .1s;
}
.op-btn:hover { transform: scale(1.06); }
.op-btn.active { outline: 3px solid var(–yellow); }
/* HISTORIQUE */
.hist { background: var(–bg-card); border-radius: 8px; padding: 10px 16px; }
.hist-title { font-size: 0.8rem; color: var(–text-med); margin-bottom: 4px; }
.hist-line { font-family: ‘Courier New’, monospace; font-size: 1.15rem; font-weight: 700; color: var(–accent2); min-height: 24px; padding: 2px 0; }
/* ACTIONS */
.actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.act-btn {
font-family: inherit; font-size: 0.9rem; font-weight: 700;
border: none; border-radius: 8px; padding: 10px 18px; cursor: pointer;
transition: transform .1s, opacity .15s;
}
.act-btn:hover { transform: scale(1.03); }
.act-btn.green { background: var(–green); color: #1a1a2e; }
.act-btn.blue { background: var(–blue); color: #1a1a2e; }
.act-btn.red { background: var(–accent); color: #fff; }
.act-btn.grey { background: var(–grey); color: #fff; }
.act-btn:disabled { opacity: .4; cursor: default; transform: none; }
/* SOLUTION AUTO */
.sol-box { background: var(–bg-card); border-radius: 8px; padding: 10px 16px; }
.sol-title { font-size: 0.8rem; color: var(–text-med); margin-bottom: 4px; }
.sol-line { font-family: ‘Courier New’, monospace; font-size: 0.9rem; color: var(–text-med); min-height: 18px; }
.sol-line.exact { color: var(–green); }
/* STATUS */
.status { text-align: center; font-size: 0.85rem; color: var(–text-dim); padding: 4px 0; min-height: 20px; }
/* MODAL */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal { background: var(–bg-card); border-radius: 12px; padding: 24px; min-width: 300px; max-width: 480px; width: 90%; }
.modal h2 { font-size: 1rem; margin-bottom: 16px; color: var(–accent2); }
.modal .row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 0.9rem; }
.modal label { width: 180px; color: var(–text-med); flex-shrink: 0; }
.modal input[type=number] { width: 80px; background: var(–bg-hdr); color: var(–text); border: 1px solid var(–border); border-radius: 6px; padding: 4px 8px; font-size: 0.9rem; }
.modal select { background: var(–bg-hdr); color: var(–text); border: 1px solid var(–border); border-radius: 6px; padding: 4px 8px; font-size: 0.9rem; }
.modal .modal-actions { display: flex; gap: 8px; margin-top: 18px; justify-content: flex-end; }
/* DETAIL MODAL */
.detail-tabs { display: flex; gap: 0; margin-bottom: 0; }
.tab-btn { background: var(–bg-sub); color: var(–text-med); border: none; padding: 8px 16px; cursor: pointer; font-size: 0.85rem; font-family: inherit; }
.tab-btn.active { background: var(–bg-card); color: var(–accent); font-weight: 700; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.reach-grid { display: grid; grid-template-columns: repeat(10, 1fr); gap: 2px; max-height: 340px; overflow-y: auto; margin-top: 8px; }
.reach-cell { font-family: ‘Courier New’, monospace; font-size: 0.75rem; text-align: center; padding: 3px 2px; border-radius: 3px; background: var(–bg-sub); color: var(–text); }
.reach-cell.is-target { background: var(–accent2); color: #1a1a2e; font-weight: 700; }
.reach-cell.is-firstgap { background: #2d5f80; color: var(–blue); }
.sol-item { background: var(–bg-sub); border-radius: 6px; padding: 8px 12px; margin-bottom: 6px; font-family: ‘Courier New’, monospace; font-size: 0.82rem; cursor: pointer; }
.sol-item:hover { background: #3a6a90; }
.sol-item .sol-preview { color: var(–text-med); font-size: 0.78rem; }
.sol-steps { display: none; margin-top: 6px; }
.sol-steps.open { display: block; }
.sol-steps span { display: block; color: var(–green); }
</style>
</head>
<body>
<header>
<h1>LE COMPTE EST BON <span style=”font-size:.6rem;font-weight:400;opacity:.7;letter-spacing:1px;”>by J.Dimijian + Claude</span></h1>
<div class=”hdr-right”>
<button class=”act-btn grey” onclick=”openStats()” style=”padding:8px 12px;”>📊</button>
<button class=”act-btn grey” onclick=”openOptions()”>Options</button>
</div>
</header>
<div class=”info-bar”>
<span id=”lbl-round” style=”color:var(–text-dim)”>—</span>
<span id=”lbl-mode” style=”color:var(–text-dim)”>CEB24</span>
<span class=”timer” id=”lbl-timer”>—</span>
<span class=”score”>Score : <span id=”lbl-score”>0</span></span>
</div>
<main>
<div class=”cible-wrap” style=”display:flex;align-items:center;gap:12px;”>
<button class=”act-btn red” id=”btn-nouveau” onclick=”newGame()” style=”padding:10px 16px;flex-shrink:0;”>Nouveau</button>
<div style=”flex:1;text-align:center;”>
<div class=”cible-label”>Cible</div>
<div class=”cible-val” id=”lbl-cible”>—</div>
</div>
</div>
<div class=”plq-section”>
<div class=”plq-title” id=”plq-title”>Plaques disponibles</div>
<div class=”plq-grid” id=”plq-grid”></div>
<!– Mode entraînement : saisie libre –>
<div id=”entrainement-zone” style=”display:none; margin-top:8px;”>
<div style=”font-size:0.8rem;color:var(–text-dim);margin-bottom:6px;”>Saisis les plaques et la cible, puis clique sur Jouer</div>
<div style=”display:flex;flex-wrap:wrap;gap:6px;align-items:center;” id=”entry-plq-row”></div>
<div style=”display:flex;align-items:center;gap:10px;margin-top:8px;”>
<span style=”font-size:0.85rem;color:var(–text-med);”>Cible :</span>
<input type=”number” id=”entry-cible” min=”1″ max=”9999″
style=”width:80px;background:var(–bg-hdr);color:var(–accent2);border:1px solid var(–border);
border-radius:6px;padding:6px 8px;font-size:1.1rem;font-weight:700;text-align:center;”>
<button class=”act-btn green” onclick=”jouerEntrainement()” style=”padding:8px 14px;”>Jouer !</button>
</div>
</div>
</div>
<div class=”cur-op” id=”cur-op”></div>
<div class=”op-row” id=”op-row” style=”display:flex;align-items:center;”>
<div style=”flex:1;display:flex;justify-content:center;gap:16px;”>
<button class=”op-btn” onclick=”onOperator(‘+’, this)”>+</button>
<button class=”op-btn” onclick=”onOperator(‘-‘, this)”>−</button>
<button class=”op-btn” onclick=”onOperator(‘X’, this)”>×</button>
<button class=”op-btn” onclick=”onOperator(‘/’, this)”>÷</button>
</div>
<button class=”op-btn” onclick=”onEffacer()” style=”background:var(–grey);font-size:1rem;flex-shrink:0;”>⌫</button>
</div>
<div class=”hist”>
<div class=”hist-title”>Calculs :</div>
<div id=”hist-lines”></div>
</div>
<div class=”actions”>
<button class=”act-btn green” onclick=”onValider()”>Valider</button>
<button class=”act-btn blue” onclick=”onResoudre()” id=”btn-resoudre” disabled>Résoudre</button>
<button class=”act-btn grey” onclick=”openDetail()” id=”btn-detail” disabled>Détail…</button>
</div>
<div class=”sol-box” id=”sol-box” style=”display:none”>
<div class=”sol-title”>Meilleure solution :</div>
<div id=”sol-lines”></div>
</div>
<div class=”status” id=”lbl-status”>Clique sur Nouveau pour commencer</div>
</main>
<!– OPTIONS MODAL –>
<div class=”modal-overlay” id=”modal-options”>
<div class=”modal”>
<h2>Options du jeu</h2>
<div style=”font-size:.8rem;color:var(–accent2);margin-bottom:6px;font-weight:700;”>— Partie —</div>
<div class=”row”>
<label>N° partie / graine (0=aléatoire)</label>
<input type=”number” id=”opt-partie” value=”0″ min=”0″ style=”width:80px;”>
<span style=”font-size:.75rem;color:var(–text-dim)”>⚠ reset score</span>
</div>
<div class=”row”><label>Nombre de tirages par partie</label><input type=”number” id=”opt-nbtirages” min=”1″ max=”999″ value=”50″ style=”width:80px;”></div>
<div style=”font-size:.8rem;color:var(–accent2);margin:8px 0 6px;font-weight:700;”>— Tirage —</div>
<div class=”row”><label>Mode de tirage</label>
<select id=”opt-tirage”>
<option value=”ceb24″>CEB 24 (classique)</option>
<option value=”ceb28″>CEB 28 (grands ×2)</option>
<option value=”alea20″>Aléatoire 20</option>
</select>
</div>
<div class=”row”><label>Nombre de plaques</label><input type=”number” id=”opt-nbplq” min=”2″ max=”8″ value=”6″></div>
<div class=”row”><label>Cible minimum</label><input type=”number” id=”opt-tmin” value=”101″></div>
<div class=”row”><label>Cible maximum</label><input type=”number” id=”opt-tmax” value=”999″></div>
<div class=”row”><label>Cible forcément atteignable</label>
<input type=”checkbox” id=”opt-exact” checked style=”width:20px;height:20px;accent-color:var(–green);cursor:pointer;”>
</div>
<div style=”font-size:.8rem;color:var(–accent2);margin:8px 0 6px;font-weight:700;”>— Jeu —</div>
<div class=”row”><label>Temps limite (s)</label><input type=”number” id=”opt-time” value=”45″></div>
<div class=”row”><label>Reprise auto résultat précédent</label>
<input type=”checkbox” id=”opt-reprise” style=”width:20px;height:20px;accent-color:var(–green);cursor:pointer;”>
</div>
<div class=”row”><label>Mode entraînement (plaques + cible libres)</label>
<input type=”checkbox” id=”opt-entrainement” style=”width:20px;height:20px;accent-color:var(–green);cursor:pointer;”>
</div>
<div style=”font-size:.8rem;color:var(–accent2);margin:8px 0 6px;font-weight:700;”>— Solveur —</div>
<div class=”row”><label>Mode résolution</label>
<select id=”opt-solve”>
<option value=”complete”>Meilleure solution</option>
<option value=”stop”>Première exacte</option>
<option value=”all”>Toutes (jusqu’à récur 3)</option>
</select>
</div>
<div class=”modal-actions”>
<button class=”act-btn green” onclick=”saveOptions()”>Appliquer</button>
<button class=”act-btn red” onclick=”closeModal(‘modal-options’)”>Annuler</button>
</div>
</div>
</div>
<!– DETAIL MODAL –>
<div class=”modal-overlay” id=”modal-detail”>
<div class=”modal” style=”max-width:640px; width:95%; max-height:90vh; overflow:hidden; display:flex; flex-direction:column;”>
<h2 id=”detail-title” style=”flex-shrink:0;”>Détail</h2>
<div class=”detail-tabs” style=”flex-shrink:0;”>
<button class=”tab-btn active” onclick=”showTab(‘tab-reach’)”>Résultats</button>
<button class=”tab-btn” onclick=”showTab(‘tab-sols’)”>Solutions exactes</button>
<button class=”tab-btn” onclick=”showTab(‘tab-best’)”>Meilleure</button>
</div>
<div style=”flex:1; overflow-y:auto; min-height:0;”>
<div id=”tab-reach” class=”tab-content active”>
<div id=”reach-info” style=”font-size:.85rem;color:var(–text-med);padding:6px 0″></div>
<div id=”reach-canvas-wrap” style=”overflow-y:auto;max-height:340px;margin-top:4px;”></div>
</div>
<div id=”tab-sols” class=”tab-content”>
<div id=”sols-list” style=”overflow-y:auto;max-height:420px;”></div>
</div>
<div id=”tab-best” class=”tab-content”>
<div id=”best-content” style=”padding:12px 0; font-family:’Courier New’,monospace; font-size:1rem;”></div>
</div>
</div>
<div style=”flex-shrink:0; border-top:1px solid var(–border); padding-top:10px; margin-top:8px; text-align:right;”>
<button class=”act-btn red” onclick=”closeModal(‘modal-detail’)”>Fermer</button>
</div>
</div>
</div>
<!– FIN DE PARTIE MODAL –>
<div class=”modal-overlay” id=”modal-fin”>
<div class=”modal” style=”max-width:480px;width:95%;text-align:center;”>
<h2 style=”font-size:1.4rem;margin-bottom:4px;”>🏁 Fin de partie !</h2>
<div id=”fin-graine” style=”font-size:.8rem;color:var(–text-dim);margin-bottom:16px;”></div>
<div id=”fin-score” style=”font-size:2.5rem;font-weight:700;color:var(–accent2);margin-bottom:4px;”></div>
<div id=”fin-moyenne” style=”font-size:.9rem;color:var(–text-med);margin-bottom:16px;”></div>
<div id=”fin-hist” style=”text-align:left;max-height:260px;overflow-y:auto;margin-bottom:16px;”></div>
<div class=”modal-actions” style=”justify-content:center;gap:12px;”>
<button class=”act-btn green” onclick=”nouvellePartie()”>Nouvelle partie</button>
<button class=”act-btn grey” onclick=”closeModal(‘modal-fin’)”>Fermer</button>
</div>
</div>
</div>
<!– STATS MODAL –>
<div class=”modal-overlay” id=”modal-stats”>
<div class=”modal” style=”max-width:580px;width:95%;max-height:90vh;overflow:hidden;display:flex;flex-direction:column;”>
<h2 style=”flex-shrink:0;”>📊 Statistiques</h2>
<div class=”detail-tabs” style=”flex-shrink:0;”>
<button class=”tab-btn active” onclick=”showStatsTab(‘st-global’)”>Globales</button>
<button class=”tab-btn” onclick=”showStatsTab(‘st-parties’)”>Parties</button>
<button class=”tab-btn” onclick=”showStatsTab(‘st-modes’)”>Modes</button>
</div>
<div style=”flex:1;overflow-y:auto;min-height:0;padding:8px 0;”>
“`
<!– Globales –>
<div id=”st-global” class=”tab-content active”></div>
<!– Parties –>
<div id=”st-parties” class=”tab-content”>
<div id=”st-parties-list” style=”font-size:.82rem;”></div>
</div>
<!– Modes –>
<div id=”st-modes” class=”tab-content”></div>
</div>
<div style=”flex-shrink:0;border-top:1px solid var(–border);padding-top:10px;margin-top:8px;display:flex;justify-content:space-between;”>
<button class=”act-btn red” onclick=”resetStats()” style=”font-size:.8rem;padding:8px 12px;”>🗑 Reset stats</button>
<button class=”act-btn grey” onclick=”closeModal(‘modal-stats’)”>Fermer</button>
</div>
“`
</div>
</div>
<script>
“use strict”;
// ══════════════════════════════════════════════════════
// HP11C RANDOM — BigInt pour éviter toute perte d’arrondi
// ══════════════════════════════════════════════════════
const KINCR = 1017980433n;
const KBASE = 1574352261n;
const K10E10 = 10000000000n;
class HP11CRandom {
constructor(partieNum = 0) {
this._next = partieNum > 0
? BigInt(partieNum) * 10000n
: BigInt(Math.floor(Math.random() * 99999999));
}
rand(limHaute) {
this._next = (this._next * KBASE + KINCR) % K10E10;
return Number(this._next * BigInt(limHaute + 1) / K10E10);
}
}
// ══════════════════════════════════════════════════════
// TIRAGE DES PLAQUES
// ══════════════════════════════════════════════════════
function extract(pool, n, rng) {
pool = […pool];
const result = [];
for (let i = 0; i < n; i++) {
const remaining = pool.length – 1;
const idx = rng.rand(remaining);
result.push(pool.splice(idx, 1)[0]);
}
return result;
}
function ceb24(n, rng) {
const pool = […Array.from({length:10},(_,i)=>i+1), …Array.from({length:10},(_,i)=>i+1), 25,50,75,100];
return extract(pool, n, rng);
}
function ceb28(n, rng) {
const base = […Array.from({length:10},(_,i)=>i+1), 25,50,75,100];
return extract([…base,…base], n, rng);
}
function alea20(n, rng) {
const pool = […Array.from({length:10},(_,i)=>i+1), …Array.from({length:10},(_,i)=>i+1)];
for (let i = 0; i < 8; i++) pool.push(rng.rand(88) + 11);
return extract(pool, n, rng);
}
function tireCible(min, max, rng) {
return rng.rand(max – min) + min;
}
// ══════════════════════════════════════════════════════
// SOLVEUR RÉCURSIF
// ══════════════════════════════════════════════════════
function calc4(a, b) {
if (a < b) { let t = a; a = b; b = t; }
const som = a + b;
const dif = (a !== b) ? a – b : 0;
const mul = (b !== 1) ? a * b : 0;
let div = 0;
if (b !== 1 && b !== 0 && a % b === 0) div = a / b;
return [som, dif, mul, div];
}
class CEBSolver {
constructor(target, mode = ‘complete’) {
this.target = target;
this.mode = mode;
this.best = null;
this.allSols = [];
this.reachable = new Set();
this._seen = new Set();
this._ops = 0;
}
_comboKey(plaques) {
return […plaques].sort((a,b)=>a-b).join(‘/’);
}
_recurse(plaques, steps) {
const n = plaques.length;
if (n < 2) return;
const key = this._comboKey(plaques);
const depth = this._depthTotal – n + 1;
if (this._seen.has(key)) {
if (this.mode === ‘all’ && depth <= 3) { /* continue */ }
else return;
}
this._seen.add(key);
if (this.mode === ‘stop’ && this.best && this.best.diff === 0) return;
for (let i = 0; i < n – 1; i++) {
for (let j = i + 1; j < n; j++) {
this._ops++;
const a = plaques[i], b = plaques[j];
const [som, dif, mul, div] = calc4(a, b);
const big = Math.max(a,b), small = Math.min(a,b);
const ops = [
[som, `${big} + ${small} = ${som}`],
dif ? [dif, `${big} – ${small} = ${dif}`] : null,
mul ? [mul, `${big} × ${small} = ${mul}`] : null,
div ? [div, `${big} / ${small} = ${div}`] : null,
];
for (const entry of ops) {
if (!entry) continue;
const [result, label] = entry;
this.reachable.add(result);
const diff = Math.abs(result – this.target);
const curSteps = […steps, label];
const bestDiff = this.best ? this.best.diff : this.target;
let isBetter = false;
if (!this.best) isBetter = true;
else if (diff < this.best.diff) isBetter = true;
else if (diff === this.best.diff && curSteps.length < this.best.depth) isBetter = true;
if (diff <= bestDiff) {
const sol = { result, diff, depth: curSteps.length, steps: curSteps };
if (diff === 0) this.allSols.push(sol);
if (isBetter) this.best = sol;
}
}
const rest = plaques.filter((_,k) => k !== i && k !== j);
for (const entry of [
mul ? [mul, `${big} × ${small} = ${mul}`] : null,
div ? [div, `${big} / ${small} = ${div}`] : null,
[som, `${big} + ${small} = ${som}`],
dif ? [dif, `${big} – ${small} = ${dif}`] : null,
]) {
if (!entry) continue;
const [val, label] = entry;
this._recurse([…rest, val], […steps, label]);
}
}
}
}
solve(plaques) {
this._seen.clear();
this._ops = 0;
this._depthTotal = plaques.length;
if (plaques.includes(this.target)) {
this.best = { result: this.target, diff: 0, depth: 0,
steps: [`La cible ${this.target} est directement une des plaques !`] };
this.allSols = [this.best];
return this.best;
}
const bestPlaque = plaques.reduce((b,x) => Math.abs(x-this.target) < Math.abs(b-this.target) ? x : b);
this.best = { result: bestPlaque, diff: Math.abs(bestPlaque-this.target), depth: 0,
steps: [`(meilleure plaque seule : ${bestPlaque})`] };
this._recurse(plaques, []);
return this.best;
}
}
// ══════════════════════════════════════════════════════
// MOTEUR DE JEU
// ══════════════════════════════════════════════════════
const cfg = {
tirageMode: ‘ceb24’, solveMode: ‘complete’,
nbPlaques: 6, targetMin: 101, targetMax: 999,
timeLimit: 45, partieNum: 0, nbTirages: 50,
exactOnly: true,
repriseAuto: true, // reprise auto résultat précédent (défaut coché)
entrainement: false,
scoreInTime: [10, 7, -5],
scoreOutTime: [ 5, 2,-10],
};
let state = {
plaques: [], target: 0,
best: null, allSols: [], reachable: new Set(),
startTime: 0, score: 0, roundNum: 0,
validated: true,
history: [], // [{tirage, target, diff, pts, inTime}]
};
let rng = null;
function getRng() {
if (!rng) rng = new HP11CRandom(cfg.partieNum);
return rng;
}
function doTirage() {
const r = getRng();
let plaques;
if (cfg.tirageMode === ‘ceb24’) plaques = ceb24(cfg.nbPlaques, r);
else if (cfg.tirageMode === ‘ceb28’) plaques = ceb28(cfg.nbPlaques, r);
else plaques = alea20(cfg.nbPlaques, r);
let target;
if (cfg.exactOnly) {
// Calcule d’abord toutes les valeurs atteignables, puis tire parmi elles
const scanner = new CEBSolver(0, ‘complete’);
scanner._depthTotal = plaques.length;
scanner._recurse(plaques, []);
const candidates = […scanner.reachable]
.filter(v => v >= cfg.targetMin && v <= cfg.targetMax)
.sort((a, b) => a – b);
if (candidates.length > 0) {
const idx = r.rand(candidates.length – 1);
target = candidates[idx];
} else {
target = tireCible(cfg.targetMin, cfg.targetMax, r); // fallback
}
} else {
target = tireCible(cfg.targetMin, cfg.targetMax, r);
}
state = { …state, plaques, target, best: null, allSols: [], reachable: new Set(),
startTime: Date.now(), roundNum: state.roundNum + 1, validated: false };
return state;
}
function doSolve() {
const solver = new CEBSolver(state.target, cfg.solveMode);
const t0 = performance.now();
const sol = solver.solve(state.plaques);
const ms = performance.now() – t0;
state.best = sol;
state.allSols = solver.allSols;
state.reachable = solver.reachable;
return { sol, solver, ms };
}
function evaluate(playerResult) {
if (state.validated) return { error: ‘Déjà évalué.’ };
const elapsed = (Date.now() – state.startTime) / 1000;
const inTime = elapsed <= cfg.timeLimit;
const diff = Math.abs(playerResult – state.target);
const tbl = inTime ? cfg.scoreInTime : cfg.scoreOutTime;
const pts = diff === 0 ? tbl[0] : diff <= 3 ? tbl[1] : tbl[2];
state.score += pts;
state.validated = true;
state.history.push({
num: state.roundNum, plaques: […state.plaques],
target: state.target, result: playerResult,
diff, pts, inTime, elapsed: +elapsed.toFixed(1),
});
if (!cfg.entrainement && state.roundNum >= cfg.nbTirages) {
setTimeout(() => openFinPartie(), 400);
}
saveSession();
return { diff, inTime, elapsed: elapsed.toFixed(1), points: pts,
total: state.score,
msg: `${inTime ? ‘✓ In time’ : ‘✗ Out of time’} (${elapsed.toFixed(1)}s) | écart=${diff} | ${pts >= 0 ? ‘+’ : ”}${pts} pts` };
}
// ══════════════════════════════════════════════════════
// UI STATE
// ══════════════════════════════════════════════════════
let phase = 1; // 1=op1, 2=oper, 3=op2
let currentOp1 = null; // {idx, val}
let currentOper = null;
let calcSteps = []; // [{op1,oper,op2,res}]
let plaqueBtns = [];
let plaqueActive = [];
let plaqueVals = [];
let timerId = null;
let elapsed = 0;
let lastSolver = null;
// ══════════════════════════════════════════════════════
// TIMER
// ══════════════════════════════════════════════════════
function startTimer() {
elapsed = 0;
stopTimer();
tick();
}
function tick() {
const rest = cfg.timeLimit – elapsed;
const t = document.getElementById(‘lbl-timer’);
t.textContent = rest >= 0 ? `${rest}s` : ‘Temps !’;
t.style.color = rest > 15 ? ‘var(–green)’ : rest > 5 ? ‘var(–accent2)’ : ‘var(–accent)’;
elapsed++;
timerId = setTimeout(tick, 1000);
}
function stopTimer() {
if (timerId) { clearTimeout(timerId); timerId = null; }
}
// ══════════════════════════════════════════════════════
// AFFICHAGE PLAQUES
// ══════════════════════════════════════════════════════
function renderPlaques() {
const grid = document.getElementById(‘plq-grid’);
const n = state.plaques.length;
grid.innerHTML = ”;
plaqueBtns = [];
// Rangée 1 — plaques initiales
for (let i = 0; i < n; i++) {
const btn = _makePlaqueBtn(i, plaqueVals[i], plaqueActive[i], false);
grid.appendChild(btn);
plaqueBtns.push(btn);
}
// Rangée 2 — emplacements résultats (n slots fantômes)
for (let i = 0; i < n; i++) {
const ghost = document.createElement(‘div’);
ghost.className = ‘plq-btn-ghost’;
ghost.dataset.slot = i;
grid.appendChild(ghost);
}
}
function _makePlaqueBtn(idx, val, active, isResult) {
const btn = document.createElement(‘button’);
btn.className = ‘plq-btn’ + (isResult ? ‘ result’ : ”) + (!active ? ‘ used’ : ”);
btn.textContent = val;
btn.disabled = !active;
btn.onclick = () => onPlaque(idx);
return btn;
}
// Ajoute un résultat dans le prochain slot fantôme de la rangée 2
function _addResultBtn(idx, val) {
const grid = document.getElementById(‘plq-grid’);
const n = state.plaques.length;
const slotIdx = idx – n;
const target = grid.querySelector(`.plq-btn-ghost[data-slot=”${slotIdx}”]`);
if (!target) return;
const btn = _makePlaqueBtn(idx, val, true, true);
grid.replaceChild(btn, target);
plaqueBtns.push(btn);
}
// Supprime le dernier résultat et remet un ghost à sa place
function _removeLastResultBtn() {
const grid = document.getElementById(‘plq-grid’);
const n = state.plaques.length;
const children = […grid.children];
for (let i = children.length – 1; i >= n; i–) {
if (!children[i].classList.contains(‘plq-btn-ghost’)) {
const slotIdx = i – n;
const ghost = document.createElement(‘div’);
ghost.className = ‘plq-btn-ghost’;
ghost.dataset.slot = slotIdx;
grid.replaceChild(ghost, children[i]);
plaqueBtns.pop();
return;
}
}
}
function refreshHist() {
const div = document.getElementById(‘hist-lines’);
div.innerHTML = ”;
for (let i = 0; i < state.plaques.length – 1; i++) {
const d = document.createElement(‘div’);
d.className = ‘hist-line’;
if (i < calcSteps.length) {
const {op1,oper,op2,res} = calcSteps[i];
d.textContent = ` ${op1} ${oper} ${op2} = ${res}`;
}
div.appendChild(d);
}
}
function setStatus(txt) { document.getElementById(‘lbl-status’).textContent = txt; }
function setCurOp(txt) { document.getElementById(‘cur-op’).textContent = txt; }
// ══════════════════════════════════════════════════════
// CHECK OPERATION
// ══════════════════════════════════════════════════════
function checkOperation(op1, oper, op2) {
if (oper === ‘+’) return { ok: true, res: op1 + op2, err: ” };
if (oper === ‘-‘) {
const res = op1 – op2;
if (res < 1) return { ok: false, res: 0, err: `Soustraction non autorisée (résultat = ${res} < 1)` };
return { ok: true, res, err: ” };
}
if (oper === ‘X’ || oper === ‘×’) return { ok: true, res: op1 * op2, err: ” };
if (oper === ‘/’) {
if (op2 === 0) return { ok: false, res: 0, err: ‘Division par zéro’ };
if (op1 % op2 !== 0) return { ok: false, res: 0, err: `Division non entière (${op1} ÷ ${op2})` };
return { ok: true, res: op1 / op2, err: ” };
}
return { ok: false, res: 0, err: ‘Opérateur inconnu’ };
}
// ══════════════════════════════════════════════════════
// INTERACTIONS JOUEUR
// ══════════════════════════════════════════════════════
function onPlaque(idx) {
if (!plaqueActive[idx]) return;
const val = plaqueVals[idx];
if (phase === 1 || phase === 2) {
// Phase 1 : choix op1 | Phase 2 : remplacement op1 (nouvelle plaque cliquée)
if (phase === 2 && currentOp1) {
// Désélectionne l’ancien op1
plaqueBtns[currentOp1.idx].classList.remove(‘selected’);
}
currentOp1 = { idx, val };
plaqueBtns[idx].classList.add(‘selected’);
phase = 2;
setCurOp(`${val} ? …`);
setStatus(`${val} sélectionné — choisis un opérateur.`);
} else if (phase === 3) {
// Clic sur la plaque déjà choisie comme op1 → désélection, retour phase 1
if (idx === currentOp1.idx) {
plaqueBtns[currentOp1.idx].classList.remove(‘selected’);
document.querySelectorAll(‘.op-btn’).forEach(b => b.classList.remove(‘active’));
currentOp1 = currentOper = null;
phase = 1;
setCurOp(”);
setStatus(‘Sélectionne une plaque.’);
return;
}
const op1 = currentOp1.val;
const { ok, res, err } = checkOperation(op1, currentOper, val);
if (!ok) { alert(err); return; }
// Désactive les deux plaques
for (const i2 of [currentOp1.idx, idx]) {
plaqueActive[i2] = false;
plaqueBtns[i2].disabled = true;
plaqueBtns[i2].classList.remove(‘selected’);
plaqueBtns[i2].classList.add(‘used’);
}
calcSteps.push({ op1, oper: currentOper, op2: val, res });
refreshHist();
saveSession();
// Bouton résultat dans la rangée 2
const newIdx = plaqueVals.length;
plaqueVals.push(res);
plaqueActive.push(true);
_addResultBtn(newIdx, res);
if (res === state.target && !state.validated) {
stopTimer();
const r = evaluate(res);
if (!r.error) {
document.getElementById(‘lbl-score’).textContent = r.total;
setStatus(`🎉 COMPTE EST BON ! ${r.msg}`);
alert(`Compte est bon !\n\n${r.msg}`);
document.getElementById(‘btn-resoudre’).disabled = false;
updateNouveauBtn();
}
resetPhase();
return;
}
// Reprise auto : le résultat devient op1 de la prochaine étape
if (cfg.repriseAuto) {
currentOp1 = { idx: newIdx, val: res };
plaqueBtns[newIdx].classList.add(‘selected’);
phase = 2;
setCurOp(`${res} ? …`);
setStatus(`Reprise : ${res} sélectionné — choisis un opérateur.`);
} else {
resetPhase();
}
}
}
function onOperator(sym, btn) {
// Autorisé en phase 2 (op1 choisi) ou phase 3 (remplacement opérateur)
if (phase !== 2 && phase !== 3) return;
document.querySelectorAll(‘.op-btn’).forEach(b => b.classList.remove(‘active’));
if (btn) btn.classList.add(‘active’);
currentOper = sym;
phase = 3;
setCurOp(`${currentOp1.val} ${sym} …`);
setStatus(`${currentOp1.val} ${sym} … — choisis la 2e plaque.`);
}
function resetPhase() {
currentOp1 = currentOper = null;
phase = 1;
setCurOp(”);
document.querySelectorAll(‘.op-btn’).forEach(b => b.classList.remove(‘active’));
setStatus(‘Sélectionne une plaque.’);
}
function onEffacer() {
if (phase === 2) {
plaqueBtns[currentOp1.idx].classList.remove(‘selected’);
resetPhase();
} else if (phase === 3) {
resetPhase();
} else if (calcSteps.length > 0) {
const {op1, op2, res} = calcSteps.pop();
// Réactive les 2 plaques sources (cherche par valeur + inactive)
for (const search of [op1, op2]) {
for (let i = 0; i < plaqueVals.length; i++) {
if (plaqueVals[i] === search && !plaqueActive[i] && i < plaqueBtns.length) {
plaqueActive[i] = true;
plaqueBtns[i].disabled = false;
plaqueBtns[i].classList.remove(‘used’);
break;
}
}
}
// Supprime le bouton résultat et remet un ghost
if (plaqueVals.length > state.plaques.length) {
_removeLastResultBtn();
plaqueActive.pop();
plaqueVals.pop();
}
refreshHist();
resetPhase();
}
}
function onValider() {
if (!calcSteps.length) { alert(‘Effectue au moins un calcul d\’abord.’); return; }
if (state.validated) { alert(‘Déjà validé — Lance un nouveau tirage !’); return; }
stopTimer();
const r = evaluate(calcSteps[calcSteps.length – 1].res);
if (r.error) { alert(r.error); return; }
document.getElementById(‘lbl-score’).textContent = r.total;
setStatus(r.msg);
alert(r.msg);
document.getElementById(‘btn-resoudre’).disabled = false;
updateNouveauBtn();
}
function onResoudre() {
setStatus(‘Calcul en cours…’);
document.getElementById(‘btn-resoudre’).disabled = true;
setTimeout(() => {
const { sol, solver, ms } = doSolve();
lastSolver = solver;
showSolution(sol, solver, ms);
document.getElementById(‘btn-resoudre’).disabled = false;
document.getElementById(‘btn-detail’).disabled = false;
}, 10);
}
function showSolution(sol, solver, ms) {
const box = document.getElementById(‘sol-box’);
const div = document.getElementById(‘sol-lines’);
box.style.display = ‘block’;
div.innerHTML = ”;
if (sol) {
for (const step of sol.steps) {
const d = document.createElement(‘div’);
d.className = ‘sol-line’ + (sol.diff === 0 ? ‘ exact’ : ”);
d.textContent = ` ${step}`;
div.appendChild(d);
}
}
const diff = sol ? (sol.diff === 0 ? ‘exact’ : `écart=${sol.diff}`) : ‘?’;
setStatus(`Résolu en ${ms.toFixed(0)} ms — ${diff} — ${solver.allSols.length} solution(s) exacte(s)`);
}
// ══════════════════════════════════════════════════════
// NOUVEAU TIRAGE
// ══════════════════════════════════════════════════════
function newGame() {
stopTimer();
hideSolution();
if (cfg.entrainement) {
// Mode entraînement : affiche la zone de saisie, pas de tirage auto
document.getElementById(‘entrainement-zone’).style.display = ‘block’;
document.getElementById(‘plq-title’).textContent = ‘Saisie libre’;
document.getElementById(‘plq-grid’).innerHTML = ”;
document.getElementById(‘lbl-cible’).textContent = ‘—’;
document.getElementById(‘lbl-round’).textContent = ‘Entraînement’;
document.getElementById(‘lbl-mode’).textContent = ‘LIBRE’;
document.getElementById(‘lbl-timer’).textContent = ‘—’;
document.getElementById(‘btn-detail’).disabled = true;
document.getElementById(‘btn-resoudre’).disabled = true;
_buildEntryPlaques(cfg.nbPlaques);
calcSteps = []; plaqueVals = []; plaqueActive = []; plaqueBtns = [];
refreshHist();
resetPhase();
setStatus(‘Saisis tes plaques et ta cible, puis clique sur Jouer !’);
return;
}
document.getElementById(‘entrainement-zone’).style.display = ‘none’;
document.getElementById(‘plq-title’).textContent = ‘Plaques disponibles’;
setStatus(‘Tirage en cours…’);
// setTimeout pour laisser le DOM se mettre à jour avant le calcul exactOnly
setTimeout(() => {
const s = doTirage();
saveSession();
document.getElementById(‘lbl-cible’).textContent = s.target;
document.getElementById(‘lbl-round’).textContent = `Tirage ${s.roundNum} / ${cfg.nbTirages}`;
document.getElementById(‘lbl-mode’).textContent = cfg.tirageMode.toUpperCase() + (cfg.exactOnly ? ‘ ✓’ : ”);
document.getElementById(‘btn-detail’).disabled = true;
document.getElementById(‘btn-resoudre’).disabled = true;
plaqueVals = […s.plaques];
plaqueActive = s.plaques.map(() => true);
calcSteps = [];
renderPlaques();
refreshHist();
resetPhase();
startTimer();
updateNouveauBtn();
setStatus(‘Sélectionne une plaque pour commencer !’);
}, 10);
}
function hideSolution() {
document.getElementById(‘sol-box’).style.display = ‘none’;
document.getElementById(‘sol-lines’).innerHTML = ”;
lastSolver = null;
}
// ── Mode entraînement ────────────────────────────────
function _buildEntryPlaques(nb) {
const row = document.getElementById(‘entry-plq-row’);
row.innerHTML = ”;
for (let i = 0; i < nb; i++) {
const inp = document.createElement(‘input’);
inp.type = ‘number’; inp.min = ‘1’; inp.max = ‘999’;
inp.placeholder = `P${i+1}`;
inp.style.cssText = ‘width:60px;background:var(–bg-hdr);color:var(–text);’ +
‘border:1px solid var(–border);border-radius:6px;padding:6px 4px;’ +
‘font-size:1rem;font-weight:700;text-align:center;’;
inp.dataset.idx = i;
row.appendChild(inp);
}
document.getElementById(‘entry-cible’).value = ”;
}
function jouerEntrainement() {
const inputs = document.querySelectorAll(‘#entry-plq-row input’);
const plaques = [];
for (const inp of inputs) {
const v = parseInt(inp.value);
if (!v || v < 1) { alert(‘Toutes les plaques doivent être remplies (>= 1)’); inp.focus(); return; }
plaques.push(v);
}
const cible = parseInt(document.getElementById(‘entry-cible’).value);
if (!cible || cible < 1) { alert(‘Saisis une cible valide (>= 1)’); return; }
document.getElementById(‘entrainement-zone’).style.display = ‘none’;
state = { …state, plaques, target: cible,
best: null, allSols: [], reachable: new Set(),
startTime: Date.now(), roundNum: state.roundNum + 1, validated: false };
document.getElementById(‘lbl-cible’).textContent = cible;
document.getElementById(‘lbl-round’).textContent = `Entraînement ${state.roundNum}`;
document.getElementById(‘btn-detail’).disabled = true;
document.getElementById(‘btn-resoudre’).disabled = true;
plaqueVals = […plaques];
plaqueActive = plaques.map(() => true);
calcSteps = [];
renderPlaques();
refreshHist();
resetPhase();
// Pas de timer en mode entraînement
document.getElementById(‘lbl-timer’).textContent = ‘Entraînement’;
setStatus(‘C\’est parti ! Sélectionne une plaque.’);
}
// ══════════════════════════════════════════════════════
// OPTIONS
// ══════════════════════════════════════════════════════
function openOptions() {
stopTimer();
document.getElementById(‘opt-tirage’).value = cfg.tirageMode;
document.getElementById(‘opt-nbplq’).value = cfg.nbPlaques;
document.getElementById(‘opt-tmin’).value = cfg.targetMin;
document.getElementById(‘opt-tmax’).value = cfg.targetMax;
document.getElementById(‘opt-time’).value = cfg.timeLimit;
document.getElementById(‘opt-partie’).value = cfg.partieNum;
document.getElementById(‘opt-nbtirages’).value = cfg.nbTirages;
document.getElementById(‘opt-solve’).value = cfg.solveMode;
document.getElementById(‘opt-exact’).checked = cfg.exactOnly;
document.getElementById(‘opt-reprise’).checked = cfg.repriseAuto;
document.getElementById(‘opt-entrainement’).checked = cfg.entrainement;
document.getElementById(‘modal-options’).classList.add(‘open’);
}
function saveOptions() {
const nb = parseInt(document.getElementById(‘opt-nbplq’).value);
const mn = parseInt(document.getElementById(‘opt-tmin’).value);
const mx = parseInt(document.getElementById(‘opt-tmax’).value);
const nbt = parseInt(document.getElementById(‘opt-nbtirages’).value);
if (mn >= mx) { alert(‘Cible min doit être < Cible max’); return; }
if (nb < 2 || nb > 8) { alert(‘Nombre de plaques : 2 à 8’); return; }
if (nbt < 1) { alert(‘Nombre de tirages >= 1’); return; }
cfg.tirageMode = document.getElementById(‘opt-tirage’).value;
cfg.solveMode = document.getElementById(‘opt-solve’).value;
cfg.nbPlaques = nb;
cfg.targetMin = mn;
cfg.targetMax = mx;
cfg.timeLimit = parseInt(document.getElementById(‘opt-time’).value);
cfg.nbTirages = nbt;
cfg.exactOnly = document.getElementById(‘opt-exact’).checked;
cfg.repriseAuto = document.getElementById(‘opt-reprise’).checked;
cfg.entrainement = document.getElementById(‘opt-entrainement’).checked;
const np = parseInt(document.getElementById(‘opt-partie’).value);
if (np !== cfg.partieNum) {
cfg.partieNum = np;
rng = null;
clearSession();
state.score = 0;
state.roundNum = 0;
state.history = [];
document.getElementById(‘lbl-score’).textContent = 0;
}
closeModal(‘modal-options’);
if (cfg.entrainement) {
newGame();
} else {
setStatus(‘Options appliquées — Lance un nouveau tirage !’);
}
}
function closeModal(id) { document.getElementById(id).classList.remove(‘open’); }
// ── Verrou bouton Nouveau en mode compétition ─────────
function updateNouveauBtn() {
const locked = cfg.partieNum > 0 && !cfg.entrainement && !state.validated;
const btn = document.getElementById(‘btn-nouveau’);
btn.disabled = locked;
btn.title = locked ? ‘Validez votre résultat avant de passer au tirage suivant’ : ”;
}
// ══════════════════════════════════════════════════════
// FIN DE PARTIE
// ══════════════════════════════════════════════════════
function openFinPartie() {
stopTimer();
recordPartie(); // sauvegarde dans localStorage
const h = state.history;
const tot = state.score;
const moy = h.length ? (tot / h.length).toFixed(1) : 0;
const gagne = h.filter(e => e.diff === 0).length;
document.getElementById(‘fin-graine’).textContent =
`Partie ${cfg.partieNum || ‘aléatoire’} — ${h.length} tirages`;
document.getElementById(‘fin-score’).textContent = `${tot > 0 ? ‘+’ : ”}${tot} pts`;
document.getElementById(‘fin-score’).style.color = tot >= 0 ? ‘var(–accent2)’ : ‘var(–accent)’;
document.getElementById(‘fin-moyenne’).textContent =
`Moyenne : ${moy} pts/tirage | Comptes exacts : ${gagne} / ${h.length}`;
const div = document.getElementById(‘fin-hist’);
div.innerHTML = ”;
const frag = document.createDocumentFragment();
h.forEach(e => {
const row = document.createElement(‘div’);
row.style.cssText = ‘display:flex;justify-content:space-between;padding:4px 8px;’ +
`border-radius:4px;margin-bottom:3px;font-size:.82rem;` +
`background:${e.diff === 0 ? ‘#1a4a2a’ : e.diff <= 3 ? ‘#2a3a1a’ : ‘#3a1a1a’};`;
row.innerHTML =
`<span style=”color:var(–text-dim)”>#${e.num}</span>` +
`<span>${e.plaques.join(‘ ‘)}</span>` +
`<span style=”color:var(–accent2);font-weight:700″>→${e.target}</span>` +
`<span style=”color:${e.diff===0?’var(–green)’:e.diff<=3?’var(–accent2)’:’var(–accent)’}”>` +
`${e.diff===0?’✓’:(‘±’+e.diff)}</span>` +
`<span style=”color:${e.pts>=0?’var(–green)’:’var(–accent)’}”>` +
`${e.pts>=0?’+’:”}${e.pts}</span>`;
frag.appendChild(row);
});
div.appendChild(frag);
document.getElementById(‘modal-fin’).classList.add(‘open’);
}
function nouvellePartie() {
closeModal(‘modal-fin’);
clearSession();
rng = null;
state.score = 0;
state.roundNum = 0;
state.history = [];
document.getElementById(‘lbl-score’).textContent = 0;
newGame();
}
// ══════════════════════════════════════════════════════
// DETAIL — canvas virtualisé pour résultats atteignables
// ══════════════════════════════════════════════════════
let _reachCanvas = null, _reachData = null;
function _buildReachCanvas(sorted, target) {
const wrap = document.getElementById(‘reach-canvas-wrap’);
wrap.innerHTML = ”;
const COL = 10;
const CW = 58; // cell width px
const CH = 22; // cell height px
const PAD = 2;
const nRows = Math.ceil(sorted.length / COL);
const totalH = nRows * (CH + PAD);
const canvas = document.createElement(‘canvas’);
canvas.style.cssText = ‘display:block;width:100%;cursor:default;’;
wrap.appendChild(canvas);
// Premier gap (pour couleur)
let firstGapVal = null;
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] !== sorted[i-1] + 1) { firstGapVal = sorted[i]; break; }
}
function draw() {
const W = wrap.clientWidth || 600;
const cw = Math.floor(W / COL);
canvas.width = W;
canvas.height = totalH;
canvas.style.height = totalH + ‘px’;
const ctx = canvas.getContext(‘2d’);
ctx.font = ’11px Courier New’;
ctx.textAlign = ‘center’;
ctx.textBaseline = ‘middle’;
for (let idx = 0; idx < sorted.length; idx++) {
const val = sorted[idx];
const row = Math.floor(idx / COL);
const col = idx % COL;
const x = col * cw;
const y = row * (CH + PAD);
let bg = ‘#2e5070’, fg = ‘#ffffff’;
if (val === target) { bg = ‘#f5a623’; fg = ‘#1a1a2e’; }
else if (val === firstGapVal) { bg = ‘#2d5f80’; fg = ‘#5dade2’; }
ctx.fillStyle = bg;
ctx.fillRect(x + 1, y + 1, cw – 2, CH – 2);
ctx.fillStyle = fg;
ctx.fillText(val, x + cw / 2, y + CH / 2);
}
}
_reachCanvas = canvas;
_reachData = { sorted, target, draw };
draw();
// Redessine si la fenêtre change de taille
const ro = new ResizeObserver(() => draw());
ro.observe(wrap);
}
function openDetail() {
if (!lastSolver) return;
const solver = lastSolver;
document.getElementById(‘detail-title’).textContent =
`Plaques : ${state.plaques.join(‘ ‘)} | Cible : ${state.target}`;
// ── Tab résultats ────────────────────────────────
const sorted = […solver.reachable].sort((a, b) => a – b);
const attein = solver.reachable.has(state.target);
document.getElementById(‘reach-info’).textContent =
`${sorted.length} résultats | Cible ${state.target} : ${attein ? ‘✓ Atteignable’ : ‘✗ Non atteignable’}`;
document.getElementById(‘reach-info’).style.color = attein ? ‘var(–green)’ : ‘var(–accent)’;
// Canvas rendu après que la modale soit visible (dimensions correctes)
requestAnimationFrame(() => _buildReachCanvas(sorted, state.target));
// ── Tab solutions exactes (virtualisé : 1 div par solution, pas par cellule) ──
const solsList = document.getElementById(‘sols-list’);
solsList.innerHTML = ”;
if (!solver.allSols.length) {
solsList.innerHTML = ‘<p style=”color:var(–text-dim);padding:16px”>Aucune solution exacte.</p>’;
} else {
const sorted2 = […solver.allSols].sort(
(a, b) => a.depth – b.depth || a.steps.join().localeCompare(b.steps.join()));
// Fragment pour batch-insert
const frag = document.createDocumentFragment();
sorted2.forEach((sol, i) => {
const div = document.createElement(‘div’);
div.className = ‘sol-item’;
div.innerHTML =
`<span style=”color:var(–text-dim);font-size:.8rem”>[${i+1}] ${sol.depth} étape(s)</span><br>` +
`<span class=”sol-preview”>${sol.steps.join(‘ → ‘)}</span>`;
// Toggle détail inline
div.onclick = () => {
const open = div.dataset.open === ‘1’;
div.dataset.open = open ? ‘0’ : ‘1’;
div.querySelector(‘.sol-detail’)?.remove();
if (!open) {
const detail = document.createElement(‘div’);
detail.className = ‘sol-detail’;
detail.style.cssText = ‘margin-top:6px;padding:6px;background:var(–bg-hdr);border-radius:4px;’;
detail.innerHTML = sol.steps.map(s =>
`<div style=”color:var(–green);font-size:.85rem”>${s}</div>`).join(”);
div.appendChild(detail);
}
};
frag.appendChild(div);
});
solsList.appendChild(frag);
}
// ── Tab meilleure ────────────────────────────────
const bestDiv = document.getElementById(‘best-content’);
bestDiv.innerHTML = ”;
if (solver.best) {
bestDiv.innerHTML =
`<div style=”font-size:1.1rem;font-weight:700;margin-bottom:12px;color:${solver.best.diff === 0 ? ‘var(–green)’ : ‘var(–accent2)’}”>` +
(solver.best.diff === 0 ? ‘Compte exact !’ : `Écart : ${solver.best.diff}`) +
`</div>` +
solver.best.steps.map(s => `<div style=”margin:4px 0;font-size:1.05rem”>${s}</div>`).join(”) +
`<div style=”margin-top:14px;font-size:.8rem;color:var(–text-dim)”>${solver._ops} opérations testées</div>`;
}
showTab(‘tab-reach’);
document.getElementById(‘modal-detail’).classList.add(‘open’);
}
function showTab(id) {
document.querySelectorAll(‘.tab-content’).forEach(t => t.classList.remove(‘active’));
document.querySelectorAll(‘.tab-btn’).forEach(b => b.classList.remove(‘active’));
document.getElementById(id).classList.add(‘active’);
const idx = [‘tab-reach’,’tab-sols’,’tab-best’].indexOf(id);
document.querySelectorAll(‘.tab-btn’)[idx].classList.add(‘active’);
}
// ══════════════════════════════════════════════════════
// PWA — manifest inline + service worker
// ══════════════════════════════════════════════════════
function initPWA() {
// Manifest injecté en data-URI pour rester monofichier
const manifest = {
name: ‘Le Compte Est Bon’,
short_name: ‘CEB’,
description: ‘Le célèbre jeu de calcul mental’,
start_url: ‘./’,
display: ‘standalone’,
background_color: ‘#1e3a52’,
theme_color: ‘#1e3a52’,
icons: [{
src: “data:image/svg+xml,%3Csvg xmlns=’http://www.w3.org/2000/svg’ viewBox=’0 0 192 192’%3E%3Crect width=’192′ height=’192′ rx=’32’ fill=’%231e3a52’/%3E%3Ctext x=’96’ y=’130′ font-size=’110′ text-anchor=’middle’ font-family=’serif’ fill=’%23f5a623’%3E%3D%3C/text%3E%3C/svg%3E”,
sizes: ‘192×192’, type: ‘image/svg+xml’, purpose: ‘any maskable’,
}],
};
const blob = new Blob([JSON.stringify(manifest)], {type: ‘application/json’});
document.getElementById(‘pwa-manifest’).href = URL.createObjectURL(blob);
// Service Worker inline (cache-first)
if (‘serviceWorker’ in navigator) {
const swCode = `
const CACHE = ‘ceb-v1’;
self.addEventListener(‘install’, e =>
e.waitUntil(caches.open(CACHE).then(c => c.add(‘/’)))
);
self.addEventListener(‘fetch’, e =>
e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)))
);
`;
const swBlob = new Blob([swCode], {type: ‘application/javascript’});
navigator.serviceWorker.register(URL.createObjectURL(swBlob))
.catch(() => {}); // silencieux si file:// ou CSP bloque
}
}
// ══════════════════════════════════════════════════════
// STATISTIQUES — localStorage
// ══════════════════════════════════════════════════════
const STATS_KEY = ‘ceb_stats_v1’;
function _emptyStats() {
return {
global: {
partiesJouees: 0, tiragesJoues: 0, scoreTotal: 0,
exacts: 0, approches: 0, rates: 0, tempsTotal: 0,
},
parGraine: {}, // { “42”: {meilleurScore, parties, date} }
parMode: {}, // { “ceb24”: {tirages, exacts, approches, rates} }
parNbPlq: {}, // { “6”: {tirages, exacts} }
historique: [], // dernières 100 parties
};
}
function loadStats() {
try {
const raw = localStorage.getItem(STATS_KEY);
return raw ? { …_emptyStats(), …JSON.parse(raw) } : _emptyStats();
} catch { return _emptyStats(); }
}
function saveStats(st) {
try { localStorage.setItem(STATS_KEY, JSON.stringify(st)); } catch {}
}
function recordPartie() {
const h = state.history;
if (!h.length) return;
const st = loadStats();
const tot = state.score;
const n = h.length;
const exacts = h.filter(e => e.diff === 0).length;
const approches= h.filter(e => e.diff > 0 && e.diff <= 3).length;
const rates = h.filter(e => e.diff > 3).length;
const tTemps = h.reduce((s, e) => s + e.elapsed, 0);
const mode = cfg.tirageMode;
const nbp = String(cfg.nbPlaques);
const graine = String(cfg.partieNum);
const date = new Date().toLocaleDateString(‘fr-FR’);
// Global
st.global.partiesJouees++;
st.global.tiragesJoues += n;
st.global.scoreTotal += tot;
st.global.exacts += exacts;
st.global.approches += approches;
st.global.rates += rates;
st.global.tempsTotal += tTemps;
// Par graine
if (cfg.partieNum > 0) {
const g = st.parGraine[graine] || { meilleurScore: -Infinity, parties: 0, date };
g.parties++;
g.date = date;
if (tot > g.meilleurScore) g.meilleurScore = tot;
st.parGraine[graine] = g;
}
// Par mode
const m = st.parMode[mode] || { tirages: 0, exacts: 0, approches: 0, rates: 0 };
m.tirages += n;
m.exacts += exacts;
m.approches += approches;
m.rates += rates;
st.parMode[mode] = m;
// Par nb plaques
const p = st.parNbPlq[nbp] || { tirages: 0, exacts: 0 };
p.tirages += n;
p.exacts += exacts;
st.parNbPlq[nbp] = p;
// Historique (max 100 parties)
st.historique.unshift({
graine: cfg.partieNum, mode, nbPlq: cfg.nbPlaques,
nbTirages: n, score: tot, exacts, approches, rates, date,
});
if (st.historique.length > 100) st.historique.length = 100;
saveStats(st);
}
function resetStats() {
if (!confirm(‘Effacer toutes les statistiques ?’)) return;
localStorage.removeItem(STATS_KEY);
renderStatsGlobal(_emptyStats());
renderStatsParties(_emptyStats());
renderStatsModes(_emptyStats());
}
// ── Rendu ─────────────────────────────────────────────
function _pct(a, b) { return b ? (a / b * 100).toFixed(1) + ‘%’ : ‘—’; }
function _avg(a, b) { return b ? (a / b).toFixed(1) : ‘—’; }
function _statRow(label, val, color) {
return `<div style=”display:flex;justify-content:space-between;padding:6px 12px;
border-bottom:1px solid var(–border);font-size:.9rem;”>
<span style=”color:var(–text-med)”>${label}</span>
<span style=”font-weight:700;color:${color||’var(–text)’};”>${val}</span>
</div>`;
}
function _sectionTitle(t) {
return `<div style=”font-size:.75rem;font-weight:700;color:var(–accent2);
padding:10px 12px 4px;letter-spacing:1px;”>${t}</div>`;
}
function renderStatsGlobal(st) {
const g = st.global;
const div = document.getElementById(‘st-global’);
const moy = g.partiesJouees ? (g.scoreTotal / g.partiesJouees).toFixed(1) : ‘—’;
div.innerHTML =
_sectionTitle(‘RÉSULTATS’) +
_statRow(‘Parties jouées’, g.partiesJouees) +
_statRow(‘Tirages joués’, g.tiragesJoues) +
_statRow(‘Score total’, (g.scoreTotal >= 0 ? ‘+’ : ”) + g.scoreTotal,
g.scoreTotal >= 0 ? ‘var(–green)’ : ‘var(–accent)’) +
_statRow(‘Score moy / partie’, moy) +
_statRow(‘Score moy / tirage’, _avg(g.scoreTotal, g.tiragesJoues)) +
_sectionTitle(‘PRÉCISION’) +
_statRow(‘Comptes exacts’, `${g.exacts} (${_pct(g.exacts, g.tiragesJoues)})`, ‘var(–green)’) +
_statRow(‘Comptes approchés’,`${g.approches} (${_pct(g.approches, g.tiragesJoues)})`, ‘var(–accent2)’) +
_statRow(‘Ratés (>3)’, `${g.rates} (${_pct(g.rates, g.tiragesJoues)})`, ‘var(–accent)’) +
_sectionTitle(‘TEMPS’) +
_statRow(‘Temps moyen / tirage’, _avg(g.tempsTotal, g.tiragesJoues) + ‘ s’) +
_statRow(‘Temps total joué’, Math.round(g.tempsTotal / 60) + ‘ min’);
}
function renderStatsParties(st) {
const div = document.getElementById(‘st-parties-list’);
if (!st.historique.length) {
div.innerHTML = ‘<p style=”color:var(–text-dim);padding:16px”>Aucune partie enregistrée.</p>’;
return;
}
const frag = document.createDocumentFragment();
st.historique.forEach((p, i) => {
const row = document.createElement(‘div’);
row.style.cssText = ‘display:grid;grid-template-columns:24px 60px 60px 1fr 48px 48px 48px 48px;’ +
‘gap:4px;align-items:center;padding:5px 8px;border-bottom:1px solid var(–border);font-size:.78rem;’;
row.innerHTML =
`<span style=”color:var(–text-dim)”>${i+1}</span>` +
`<span style=”color:var(–text-dim)”>${p.date}</span>` +
`<span style=”color:var(–text-med)”>${p.graine || ‘aléa’}</span>` +
`<span style=”color:var(–text-med)”>${p.mode} ${p.nbPlq}♟ ×${p.nbTirages}</span>` +
`<span style=”font-weight:700;color:${p.score>=0?’var(–green)’:’var(–accent)’};”>${p.score>=0?’+’:”}${p.score}</span>` +
`<span style=”color:var(–green)”>✓${p.exacts}</span>` +
`<span style=”color:var(–accent2)”>~${p.approches}</span>` +
`<span style=”color:var(–accent)”>✗${p.rates}</span>`;
frag.appendChild(row);
});
// En-tête
const hdr = document.createElement(‘div’);
hdr.style.cssText = ‘display:grid;grid-template-columns:24px 60px 60px 1fr 48px 48px 48px 48px;’ +
‘gap:4px;padding:4px 8px;font-size:.7rem;color:var(–text-dim);border-bottom:2px solid var(–border);’;
hdr.innerHTML = ‘<span>#</span><span>Date</span><span>Graine</span><span>Mode</span>’ +
‘<span>Score</span><span>✓</span><span>~</span><span>✗</span>’;
div.innerHTML = ”;
div.appendChild(hdr);
div.appendChild(frag);
}
function renderStatsModes(st) {
const div = document.getElementById(‘st-modes’);
let html = _sectionTitle(‘PAR MODE DE TIRAGE’);
const modes = { ceb24: ‘CEB 24’, ceb28: ‘CEB 28’, alea20: ‘Aléatoire 20’ };
for (const [key, label] of Object.entries(modes)) {
const m = st.parMode[key];
if (!m || !m.tirages) continue;
html += `<div style=”padding:4px 12px 2px;font-size:.85rem;color:var(–text);font-weight:700″>${label}</div>`;
html += _statRow(‘Tirages’, m.tirages);
html += _statRow(‘Exacts’, `${m.exacts} (${_pct(m.exacts, m.tirages)})`, ‘var(–green)’);
html += _statRow(‘Approchés’, `${m.approches} (${_pct(m.approches, m.tirages)})`, ‘var(–accent2)’);
}
html += _sectionTitle(‘PAR NOMBRE DE PLAQUES’);
for (const [nbp, p] of Object.entries(st.parNbPlq).sort((a,b)=>+a[0]-+b[0])) {
html += _statRow(`${nbp} plaques`, `${p.tirages} tirages — ${_pct(p.exacts, p.tirages)} exacts`);
}
html += _sectionTitle(‘PAR GRAINE (top 10 meilleures)’);
const graines = Object.entries(st.parGraine)
.sort((a,b) => b[1].meilleurScore – a[1].meilleurScore).slice(0, 10);
for (const [g, d] of graines) {
html += _statRow(`Graine ${g}`,
`Meilleur : ${d.meilleurScore >= 0 ? ‘+’ : ”}${d.meilleurScore} (${d.parties} partie(s))`,
‘var(–accent2)’);
}
if (!graines.length) html += ‘<p style=”color:var(–text-dim);padding:12px”>Aucune partie avec graine fixe.</p>’;
div.innerHTML = html;
}
function showStatsTab(id) {
document.querySelectorAll(‘#modal-stats .tab-content’).forEach(t => t.classList.remove(‘active’));
document.querySelectorAll(‘#modal-stats .tab-btn’).forEach(b => b.classList.remove(‘active’));
document.getElementById(id).classList.add(‘active’);
const idx = [‘st-global’,’st-parties’,’st-modes’].indexOf(id);
document.querySelectorAll(‘#modal-stats .tab-btn’)[idx].classList.add(‘active’);
}
function openStats() {
const st = loadStats();
renderStatsGlobal(st);
renderStatsParties(st);
renderStatsModes(st);
showStatsTab(‘st-global’);
document.getElementById(‘modal-stats’).classList.add(‘open’);
}
// ══════════════════════════════════════════════════════
// PERSISTANCE SESSION — localStorage
// ══════════════════════════════════════════════════════
const SESSION_KEY = ‘ceb_session_v1’;
function saveSession() {
if (cfg.entrainement) return; // pas de persistance en mode entraînement
try {
const session = {
cfg: { …cfg },
state: {
plaques: state.plaques,
target: state.target,
score: state.score,
roundNum: state.roundNum,
validated: state.validated,
history: state.history,
},
rngNext: rng ? rng._next.toString() : null,
plaqueVals,
plaqueActive,
calcSteps,
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
} catch {}
}
function clearSession() {
try { localStorage.removeItem(SESSION_KEY); } catch {}
}
function restoreSession() {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return false;
const s = JSON.parse(raw);
if (!s.state || !s.cfg) return false;
// Restaure cfg
Object.assign(cfg, s.cfg);
// Restaure state
state.plaques = s.state.plaques || [];
state.target = s.state.target || 0;
state.score = s.state.score || 0;
state.roundNum = s.state.roundNum || 0;
state.validated = s.state.validated ?? true;
state.history = s.state.history || [];
state.startTime = Date.now(); // repart du moment du refresh
// Restaure RNG
if (s.rngNext) {
rng = new HP11CRandom(0);
rng._next = BigInt(s.rngNext);
}
// Restaure UI state
plaqueVals = s.plaqueVals || [];
plaqueActive = s.plaqueActive || [];
calcSteps = s.calcSteps || [];
return state.plaques.length > 0;
} catch { return false; }
}
function _restoreUI() {
// Reconstruit l’écran depuis le state restauré
document.getElementById(‘entrainement-zone’).style.display = ‘none’;
document.getElementById(‘plq-title’).textContent = ‘Plaques disponibles’;
document.getElementById(‘lbl-cible’).textContent = state.target;
document.getElementById(‘lbl-round’).textContent = `Tirage ${state.roundNum} / ${cfg.nbTirages}`;
document.getElementById(‘lbl-mode’).textContent = cfg.tirageMode.toUpperCase() + (cfg.exactOnly ? ‘ ✓’ : ”);
document.getElementById(‘lbl-score’).textContent = state.score;
document.getElementById(‘btn-resoudre’).disabled = !state.validated;
document.getElementById(‘btn-detail’).disabled = true;
renderPlaques();
// Remet les boutons résultats dans la grille
const n = state.plaques.length;
for (let i = n; i < plaqueVals.length; i++) {
_addResultBtn(i, plaqueVals[i]);
// Remet l’état used sur les plaques sources
}
// Remet l’état used sur les boutons plaques initiales
for (let i = 0; i < n; i++) {
if (!plaqueActive[i] && plaqueBtns[i]) {
plaqueBtns[i].disabled = true;
plaqueBtns[i].classList.add(‘used’);
}
}
refreshHist();
resetPhase();
updateNouveauBtn();
if (!state.validated) {
startTimer();
setStatus(‘Partie restaurée — continue !’);
} else {
document.getElementById(‘lbl-timer’).textContent = ‘—’;
setStatus(‘Partie restaurée — valide ou lance un nouveau tirage.’);
}
}
window.onload = () => {
initPWA();
if (restoreSession()) {
_restoreUI();
} else {
newGame();
}
};
</script>
</body>
</html>
Fin
