// ==UserScript==
// @name Crossword Daily JVC
// @namespace https://jvscript.fr/
// @version 0.3.0
// @description Mots croisés quotidiens sur les forums JVC : une grille par jour, identique pour tout le monde. Chrono, série et partage du résultat, 100 % local.
// @match https://www.jeuxvideo.com/forums/*
// @match https://jeuxvideo.com/forums/*
// @author seblenonsaint
// @license MIT
// @homepageURL https://github.com/seblenonsaint/Crossword-Daily-JVC
// @supportURL https://github.com/seblenonsaint/Crossword-Daily-JVC/issues
// @downloadURL https://raw.githubusercontent.com/seblenonsaint/Crossword-Daily-JVC/main/crossword-daily-jvc.user.js
// @updateURL https://raw.githubusercontent.com/seblenonsaint/Crossword-Daily-JVC/main/crossword-daily-jvc.user.js
// @noframes
// @grant GM_addStyle
// @run-at document-idle
// ==/UserScript==
/*
* Crossword Daily JVC — v0.3.0
*
* Aucune requête réseau, aucune donnée envoyée : tout reste dans le localStorage
* du navigateur. La grille du jour est calculée à partir de la date (fuseau
* Europe/Paris), donc identique pour tous les joueurs sans serveur.
*
* Limites assumées (voir README / rapport d'audit) :
* - un userscript est lisible et modifiable par son utilisateur : le score est
* DÉCLARATIF et ne peut pas être garanti sans serveur ;
* - la solution est calculable par quiconque lit ce fichier.
*/
(() => {
'use strict';
if (document.getElementById('cdj-launch')) return; // garde anti double injection
/* — bloc sans accès au DOM, extrait tel quel par les tests Node */
const VERSION = '0.3.0';
const EPOCH = '2026-10-23'; // défi n°1
const HINT_PENALTY = 30; // secondes ajoutées par indice
const CHECK_PENALTY = 15; // secondes ajoutées par vérification
// Taille de grille selon le jour (0 = dimanche) : courbe de difficulté prévisible.
const SIZE_BY_WEEKDAY = [11, 7, 7, 9, 9, 11, 13];
const MIN_WORDS = { 5: 4, 7: 7, 9: 10, 11: 13, 13: 16 };
const RAW_DICTIONARY = `CHAT|Félin domestique;CHIEN|Animal qui aboie;LUNE|Satellite de la Terre;SOLEIL|Notre étoile;MER|Étendue d'eau salée;LAC|Étendue d'eau intérieure;RIVE|Bord d'une rivière;PLAGE|Rivage sablonneux;VAGUE|Ondulation de l'eau;ORAGE|Tonnerre et éclairs;NUAGE|Masse de vapeur dans le ciel;PLUIE|Précipitation liquide;NEIGE|Précipitation hivernale en flocons;VENT|Air en mouvement;ARBRE|Végétal à tronc;FORET|Étendue boisée;FLEUR|Partie colorée d'une plante;ROSE|Rosacée à épines;TIGE|Axe d'une plante;RACINE|Partie souterraine;FEUILLE|Organe de la photosynthèse;FRUIT|Se mange, après la floraison;POMME|Se cueille sur un pommier;POIRE|Se cueille sur un poirier;PECHE|Velouté, avec un noyau;PRUNE|Se cueille sur un prunier;RAISIN|Pousse en grappes sur la vigne;CERISE|Petite boule à queue du cerisier;MELON|Cucurbitacée sucrée;CITRON|Agrume très acide;ORANGE|Agrume sucré qui nomme une couleur;TABLE|Meuble pour les repas;CHAISE|Siège à dossier;PORTE|Ouverture mobile;FENETRE|Ouverture vitrée;MAISON|Lieu d'habitation;TOIT|Couverture d'un bâtiment;MUR|Paroi verticale;SALON|Pièce de réception;CUISINE|Pièce des repas;LIVRE|Ouvrage relié à lire;PAGE|Côté d'un feuillet imprimé;STYLO|Instrument à encre;CRAYON|Instrument à mine;PAPIER|Support d'écriture;LETTRE|Signe de l'alphabet;MOT|Unité du dictionnaire;PHRASE|Énoncé complet et ponctué;TEXTE|Contenu écrit d'un document;ECOLE|Lieu d'enseignement;CLASSE|Groupe d'apprenants;ELEVE|Personne qui apprend;PROF|Enseignant familier;COURS|Leçon;EXAMEN|Épreuve d'évaluation;NOTE|Résultat scolaire;MATHS|Discipline des équations;NOMBRE|Objet servant à compter;CHIFFRE|Symbole numérique;CARRE|Quadrilatère régulier;CERCLE|Courbe fermée autour d'un centre;ANGLE|Ouverture entre deux droites;LIGNE|Trait continu;POINT|Lieu sans dimension en géométrie;FORME|Aspect extérieur d'une figure;TEMPS|Ce que mesure l'horloge;HEURE|Vingt-quatrième partie d'une journée;MINUTE|Soixante secondes;JOUR|Durée d'une rotation de la Terre;NUIT|Période d'obscurité;MATIN|Début de journée;SOIR|Fin de journée;SEMAINE|Du lundi au dimanche;MOIS|Un douzième d'an;ANNEE|Période allant de janvier à décembre;HIVER|Saison froide;PRINTEMPS|Saison des bourgeons;AUTOMNE|Saison des vendanges;ETE|Saison chaude;ROUTE|Voie de circulation;TRAIN|Transport sur rails;AVION|Appareil volant;VELO|Deux-roues à pédales;BATEAU|Véhicule flottant;VILLE|Grande agglomération;VILLAGE|Petite agglomération;RUE|Voie urbaine;PLACE|Espace public;PONT|Franchit une rivière;AMI|Personne liée par l'amitié;AMIE|Camarade, au féminin;FAMILLE|Personnes apparentées;ENFANT|Jeune être humain;PARENT|Père ou mère;FRERE|Garçon de la fratrie;SOEUR|Fille de la fratrie;JEU|Activité ludique;JOUEUR|Participant d'une partie;SCORE|Total marqué;REGLE|Consigne à respecter;ENIGME|Question à résoudre;INDICE|Aide à la résolution;REPONSE|Solution à une question;SECRET|Information cachée;IDEE|Pensée;LOGIQUE|Étude du raisonnement;MEMOIRE|Faculté de se souvenir;SAVOIR|Connaissances;ESPRIT|Intelligence, ou fantôme;RAISON|Bon sens, faculté de juger;RIRE|Exprimer sa gaieté;JOIE|Sentiment de bonheur;PEUR|Émotion face au danger;CALME|Absence d'agitation;PAIX|Absence de guerre;FORCE|Capacité d'action;VITESSE|Distance par durée;ENERGIE|Capacité de travail;MUSIQUE|Art de combiner mélodies et rythmes;CHANT|Mélodie exécutée sans instrument;DANSE|Mouvement en rythme;IMAGE|Reflet que renvoie un miroir;PHOTO|Cliché pris avec un appareil;FILM|Œuvre cinématographique;SCENE|Partie d'une pièce;ECRAN|Surface d'affichage;RADIO|Média sonore;VOIX|Instrument de la parole;SON|Vibration audible;BRUIT|Nuisance sonore;MAIN|Extrémité du bras;PIED|Extrémité de la jambe;TETE|Partie supérieure du corps;OEIL|Organe de la vue;NEZ|Organe de l'odorat;BOUCHE|Ouverture du visage;COEUR|Organe central de la circulation;SANG|Liquide des vaisseaux;BLEU|Couleur du ciel clair;ROUGE|Couleur de la tomate mûre;VERT|Couleur de l'herbe;BLANC|Couleur du lait;NOIR|Couleur de l'encre;GRIS|Couleur de la cendre;FER|Métal de l'acier;BOIS|Matière du tronc;PIERRE|Matériau minéral;VERRE|Matériau des vitres`;
const dictionary = RAW_DICTIONARY.split(';')
.map((s) => { const [w, clue] = s.split('|'); return { w, clue }; })
.filter((e) => /^[A-Z]{3,}$/.test(e.w) && e.clue);
function hash(s) {
let h = 2166136261;
for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
return h >>> 0;
}
function random(seed) { // mulberry32
return () => {
seed += 0x6D2B79F5;
let t = seed;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Date du jour à Paris, "AAAA-MM-JJ". formatToParts évite de dépendre du format d'une locale.
function parisDate(now = new Date()) {
try {
const parts = new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(now);
const get = (t) => parts.find((p) => p.type === t).value;
return `${get('year')}-${get('month')}-${get('day')}`;
} catch {
return now.toISOString().slice(0, 10);
}
}
const utcDay = (d) => Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10));
const prevDay = (d) => new Date(utcDay(d) - 864e5).toISOString().slice(0, 10);
const weekday = (d) => new Date(utcDay(d)).getUTCDay();
const dayNumber = (d) => Math.round((utcDay(d) - utcDay(EPOCH)) / 864e5) + 1;
function formatTime(sec) {
sec = Math.max(0, Math.floor(sec));
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
const mm = String(m).padStart(2, '0'), ss = String(s).padStart(2, '0');
return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
function generate(s, n) {
const rnd = random(s);
const b = Array.from({ length: n }, () => Array(n).fill(''));
const entries = [];
const pool = dictionary
.filter((e) => e.w.length <= n)
.sort((a, c) => hash(a.w + s) - hash(c.w + s));
function fit(w, y, x, d) {
const dy = d === 'V' ? 1 : 0, dx = 1 - dy;
if (y < 0 || x < 0 || y + dy * (w.length - 1) >= n || x + dx * (w.length - 1) >= n) return -1;
if (y - dy >= 0 && x - dx >= 0 && b[y - dy][x - dx]) return -1;
if (y + dy * w.length < n && x + dx * w.length < n && b[y + dy * w.length][x + dx * w.length]) return -1;
let cross = 0;
for (let i = 0; i < w.length; i++) {
const Y = y + dy * i, X = x + dx * i;
if (b[Y][X]) {
if (b[Y][X] !== w[i]) return -1;
cross++;
} else if (d === 'H'
? ((Y > 0 && b[Y - 1][X]) || (Y < n - 1 && b[Y + 1][X]))
: ((X > 0 && b[Y][X - 1]) || (X < n - 1 && b[Y][X + 1]))) {
return -1;
}
}
return cross;
}
function put(e, y, x, d) {
for (let i = 0; i < e.w.length; i++) b[y + (d === 'V' ? i : 0)][x + (d === 'H' ? i : 0)] = e.w[i];
entries.push({ ...e, y, x, d });
}
const first = pool.find((e) => e.w.length >= 4);
if (!first) return null;
put(first, Math.floor(n / 2), Math.floor((n - first.w.length) / 2), 'H');
for (let pass = 0; pass < 3; pass++) {
for (const e of pool) {
if (entries.some((p) => p.w === e.w)) continue;
let best = null;
for (const d of ['H', 'V']) {
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const cross = fit(e.w, y, x, d);
if (cross < 1) continue;
const score = cross * 100 - Math.abs(y - n / 2) - Math.abs(x - n / 2) + rnd() * 4;
if (!best || score > best.score) best = { y, x, d, score };
}
}
}
if (best) put(e, best.y, best.x, best.d);
}
}
// Contrôle d'intégrité : toute suite de 2+ lettres doit être un mot voulu (pas de mot accidentel).
const runs = [];
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (!b[y][x]) continue;
if (x === 0 || !b[y][x - 1]) {
let w = '';
for (let X = x; X < n && b[y][X]; X++) w += b[y][X];
if (w.length > 1) runs.push(`H:${y}:${x}:${w}`);
}
if (y === 0 || !b[y - 1][x]) {
let w = '';
for (let Y = y; Y < n && b[Y][x]; Y++) w += b[Y][x];
if (w.length > 1) runs.push(`V:${y}:${x}:${w}`);
}
}
}
const expected = entries.map((e) => `${e.d}:${e.y}:${e.x}:${e.w}`);
if (runs.length !== expected.length || runs.some((k) => !expected.includes(k))
|| !entries.some((e) => e.d === 'V')) return null;
let num = 0;
const numbers = Array.from({ length: n }, () => Array(n).fill(0));
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (entries.some((e) => e.y === y && e.x === x)) numbers[y][x] = ++num;
}
}
return {
b,
numbers,
entries: entries.map((e) => {
const cells = [...e.w].map((_, i) => (e.y + (e.d === 'V' ? i : 0)) * n + e.x + (e.d === 'H' ? i : 0));
return { ...e, num: numbers[e.y][e.x], cells };
}),
};
}
// Grille du jour : déterministe (même date => même grille pour tout le monde).
function buildPuzzle(date) {
const seed = hash('cdj03:' + date);
const n = SIZE_BY_WEEKDAY[weekday(date)];
const target = MIN_WORDS[n];
let puzzle = null;
for (let i = 0; i < 150; i++) {
const p = generate((seed + Math.imul(i + 1, 2654435761)) >>> 0, n);
if (p && (!puzzle || p.entries.length > puzzle.entries.length)) puzzle = p;
if (puzzle && puzzle.entries.length >= target) break;
}
return puzzle && { ...puzzle, n, seed, date };
}
// Série en cours (jours consécutifs terminés, tolérante si aujourd'hui n'est pas encore joué) et meilleure série.
function computeStreaks(history, today) {
const days = Object.keys(history).sort();
let best = 0, run = 0, prev = null;
for (const d of days) {
run = prev && prevDay(d) === prev ? run + 1 : 1;
best = Math.max(best, run);
prev = d;
}
let current = 0;
let d = history[today] ? today : prevDay(today);
while (history[d]) { current++; d = prevDay(d); }
return { current, best };
}
/* */
// ───────────────────────────── Initialisation ─────────────────────────────
const DEBUG = (() => { try { return localStorage.getItem('cdj:debug') === '1'; } catch { return false; } })();
const log = (...a) => { if (DEBUG) console.log('[CDJ]', ...a); };
let date = parisDate();
let puzzle;
try {
puzzle = buildPuzzle(date);
} catch (err) {
console.error('[CDJ] Erreur de génération', err);
return;
}
if (!puzzle) { console.warn('[CDJ] Génération impossible'); return; }
const { b, entries, numbers, n } = puzzle;
const dayNum = dayNumber(date);
const KEY_DAY = `cdj:v03:${date}`;
const KEY_STATS = 'cdj:v03:stats';
const entryId = (e) => `${e.d}-${e.num}`;
const entriesAt = Array.from({ length: n * n }, () => []);
for (const e of entries) for (const i of e.cells) entriesAt[i].push(e);
const order = [...entries].sort((a, c) => (a.d === c.d ? a.num - c.num : a.d === 'H' ? -1 : 1));
const isLetter = (i) => !!b[Math.floor(i / n)][i % n];
const solutionAt = (i) => b[Math.floor(i / n)][i % n];
// ───────────────────────────── Stockage ─────────────────────────────
let storageOk = true;
const readJSON = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
const writeJSON = (k, v) => {
try { localStorage.setItem(k, JSON.stringify(v)); return true; } catch { storageOk = false; return false; }
};
const clampInt = (v, max) => (Number.isFinite(v) && v >= 0 ? Math.min(Math.floor(v), max) : 0);
function sanitize(raw) {
const s = { rev: 0, cells: Array(n * n).fill(''), activeMs: 0, hints: 0, checks: 0, finalSeconds: null };
if (!raw || typeof raw !== 'object') return s;
if (Array.isArray(raw.cells) && raw.cells.length === n * n) {
s.cells = raw.cells.map((c, i) => (isLetter(i) && typeof c === 'string' && /^[A-Z]$/.test(c) ? c : ''));
}
s.rev = clampInt(raw.rev, 1e9);
s.activeMs = clampInt(raw.activeMs, 864e5);
s.hints = clampInt(raw.hints, 999);
s.checks = clampInt(raw.checks, 9999);
s.finalSeconds = Number.isFinite(raw.finalSeconds) && raw.finalSeconds >= 0 ? Math.floor(raw.finalSeconds) : null;
return s;
}
function loadStats() {
const raw = readJSON(KEY_STATS);
const history = {};
if (raw && raw.history && typeof raw.history === 'object') {
for (const [d, r] of Object.entries(raw.history)) {
if (/^\d{4}-\d{2}-\d{2}$/.test(d) && r && Number.isFinite(r.sec) && r.sec >= 0) {
history[d] = { sec: Math.floor(r.sec), hints: clampInt(r.hints, 999), checks: clampInt(r.checks, 9999), n: clampInt(r.n, 99) };
}
}
}
return { history };
}
let state = sanitize(readJSON(KEY_DAY));
let stats = loadStats();
const save = () => writeJSON(KEY_DAY, state);
const touch = () => { state.rev++; save(); };
const scoreSeconds = () => Math.floor(state.activeMs / 1000) + state.hints * HINT_PENALTY + state.checks * CHECK_PENALTY;
const finished = () => state.finalSeconds !== null;
// ───────────────────────────── Styles ─────────────────────────────
const css = `
#cdj-launch{--cdj-accent:#6559d8;display:inline-flex;align-items:center;gap:6px;box-sizing:border-box;min-height:34px;margin:0 0 0 12px;padding:6px 12px;border:0;border-radius:8px;background:var(--cdj-accent);color:#fff;font:600 13px/1.2 Arial,sans-serif;letter-spacing:0;text-transform:none;text-shadow:none;white-space:nowrap;vertical-align:middle;cursor:pointer;float:none;position:static}
#cdj-launch:hover{filter:brightness(1.12)}
#cdj-launch:focus-visible{outline:2px solid #fff;outline-offset:2px;box-shadow:0 0 0 4px var(--cdj-accent)}
#cdj-launch.floating{position:fixed;left:12px;bottom:12px;z-index:2147483645;margin:0;box-shadow:0 4px 14px #0006}
#cdj-panel{--cdj-accent:#6559d8;--cdj-bg:#202127;--cdj-fg:#f5f5f5;--cdj-muted:#c3c3cc;position:fixed;right:16px;top:70px;z-index:2147483646;width:min(570px,calc(100vw - 32px));max-height:calc(100vh - 85px);overflow:auto;padding:16px;background:var(--cdj-bg);color:var(--cdj-fg);border:1px solid #555;border-radius:12px;box-shadow:0 12px 36px #0008;font:14px/1.4 Arial,sans-serif;text-align:left}
#cdj-panel[hidden],#cdj-panel [hidden]{display:none!important}
#cdj-panel *{box-sizing:border-box}
#cdj-panel .hd{position:sticky;top:-16px;z-index:5;display:flex;justify-content:space-between;align-items:center;margin:-16px -16px 4px;padding:12px 16px 6px;background:var(--cdj-bg)}
#cdj-panel h2{margin:0;color:#fff;font-size:18px}
#cdj-panel h3{margin:8px 0 2px;font-size:14px;color:#fff}
#cdj-panel .close{background:none;border:0;color:#fff;font-size:28px;line-height:1;cursor:pointer;padding:0 6px}
#cdj-panel .meta{margin:4px 0;font-size:12px;color:var(--cdj-muted)}
#cdj-panel .bar{display:flex;flex-wrap:wrap;gap:4px 14px;align-items:baseline;margin:6px 0}
#cdj-panel .timer{font:700 18px/1 monospace;color:#fff}
#cdj-panel .pen{font-size:12px;color:var(--cdj-muted)}
#cdj-panel .banner{margin:6px 0;padding:8px;border-radius:6px;background:#5b4a14;color:#ffe9a8}
#cdj-panel .cluebar{display:flex;align-items:stretch;gap:6px;margin:8px 0}
#cdj-panel .cluebar button{flex:0 0 40px;min-height:40px;background:#33353f;color:#fff;border:0;border-radius:6px;font-size:20px;cursor:pointer}
#cdj-panel .cluebar .cur{flex:1;margin:0;padding:6px 8px;background:#2b2d36;border-radius:6px;min-height:40px;display:flex;align-items:center}
#cdj-panel .gridwrap{overflow-x:auto;margin:8px auto;max-width:430px}
#cdj-panel .grid{display:grid;gap:2px;margin:0 auto}
#cdj-panel .cell{position:relative;aspect-ratio:1;min-width:0;background:#fff}
#cdj-panel .cell.block{background:#444650}
#cdj-panel .cell.active{background:#ddd9ff}
#cdj-panel .cell.locked{background:#b8e8c4}
#cdj-panel .cell.current{outline:3px solid #7464ec;outline-offset:-3px;z-index:1}
#cdj-panel .cell small{position:absolute;top:1px;left:2px;color:#333;font-size:10px;line-height:1;pointer-events:none}
#cdj-panel .cell input{width:100%;height:100%;min-width:0;border:0;outline:0;background:transparent;color:#171717;text-align:center;padding:8px 0 0;font:700 16px Arial,sans-serif;text-transform:uppercase;caret-color:transparent;border-radius:0}
#cdj-panel .cell.locked input{color:#12532b}
#cdj-panel .clues{display:grid;grid-template-columns:1fr 1fr;gap:12px}
#cdj-panel ol{margin:0;padding-left:27px;line-height:1.45}
#cdj-panel li{padding:4px 3px;cursor:pointer;border-radius:4px}
#cdj-panel li.chosen{background:#45406b}
#cdj-panel li.solved{color:#86e5a5;text-decoration:line-through;text-decoration-thickness:2px}
#cdj-panel li.solved::after{content:' ✓';text-decoration:none;display:inline-block}
#cdj-panel .actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px}
#cdj-panel .actions button{min-height:40px;background:var(--cdj-accent);color:#fff;border:0;border-radius:6px;padding:9px 12px;cursor:pointer;font:600 13px Arial,sans-serif}
#cdj-panel .actions button:disabled{opacity:.45;cursor:default}
#cdj-panel .status{min-height:22px;margin-top:8px;color:#b8efc5}
#cdj-panel .result{margin-top:8px;padding:10px;border-radius:8px;background:#1f3b2a;color:#d6f5de}
#cdj-panel .fallback{width:100%;min-height:80px;margin-top:6px;background:#111;color:#fff;border:1px solid #666;border-radius:6px;padding:6px;font:13px monospace}
#cdj-panel details{margin-top:8px;color:var(--cdj-muted);font-size:13px}
#cdj-panel summary{cursor:pointer;color:#fff}
#cdj-panel .stats{margin:8px 0 0;font-size:12px;color:var(--cdj-muted)}
#cdj-panel :focus-visible{outline:2px solid #aaa3ff;outline-offset:1px}
#cdj-panel .cell input:focus-visible{outline:0}
@media(max-width:500px){#cdj-panel{left:8px;right:8px;top:8px;width:auto;max-height:calc(100vh - 16px);padding:12px}#cdj-panel .hd{top:-12px;margin:-12px -12px 4px;padding:10px 12px 6px}#cdj-panel .clues{grid-template-columns:1fr}}`;
if (typeof GM_addStyle === 'function') GM_addStyle(css);
else { const st = document.createElement('style'); st.textContent = css; document.head.append(st); }
// ───────────────────────────── DOM ─────────────────────────────
const launch = document.createElement('button');
launch.id = 'cdj-launch';
launch.type = 'button';
launch.textContent = '🧩 Mots du jour';
launch.title = 'Ouvrir le défi de mots croisés du jour';
launch.setAttribute('aria-expanded', 'false');
launch.setAttribute('aria-controls', 'cdj-panel');
const panel = document.createElement('section');
panel.id = 'cdj-panel';
panel.hidden = true;
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'Mots croisés du jour');
panel.innerHTML = `
🧩 Défi du jour
⏱ 00:00
Un nouveau défi est disponible.
Horizontalement
Verticalement
Règles et calcul du score
Une seule grille par jour, identique pour tous les joueurs (calculée d'après la date à Paris). Saisissez les lettres au clavier, les accents sont ignorés. Un mot juste se verrouille. Touches : flèches pour se déplacer, Espace ou Entrée pour changer de sens, Échap pour fermer.
Temps final = temps actif + ${HINT_PENALTY} s par indice + ${CHECK_PENALTY} s par vérification. Plus bas = meilleur. Le chrono ne tourne que si le panneau est ouvert et l'onglet visible.
Votre progression et vos stats sont stockées uniquement dans ce navigateur. Les scores partagés sont déclaratifs : rien ne peut les vérifier sans serveur.
`;
document.body.append(panel);
const $ = (sel) => panel.querySelector(sel);
const gridEl = $('.grid'), statusEl = $('.status'), timerEl = $('.timer'), penEl = $('.pen');
const curEl = $('.cur'), resultEl = $('.result'), statsEl = $('.stats'), bannerEl = $('.banner');
const diffNames = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'];
$('.meta').textContent = `Défi n°${dayNum > 0 ? dayNum : '–'} · ${diffNames[weekday(date)]} ${date} · grille ${n} × ${n} · ${entries.length} mots`;
gridEl.style.gridTemplateColumns = `repeat(${n},minmax(22px,1fr))`;
const inputs = Array(n * n).fill(null);
const boxes = Array(n * n).fill(null);
const clueItems = new Map();
let selected = order[0];
let pendingDir = null;
let pointerWasFocused = false;
let currentIdx = -1;
const dirLabel = (e) => (e.d === 'H' ? 'Horizontalement' : 'Verticalement');
function setCurrent(i) {
if (currentIdx >= 0 && boxes[currentIdx]) { boxes[currentIdx].classList.remove('current'); inputs[currentIdx].tabIndex = -1; }
currentIdx = i;
boxes[i].classList.add('current');
inputs[i].tabIndex = 0;
}
function choose(e, { focus = true, cell = null } = {}) {
selected = e;
boxes.forEach((el) => el && el.classList.remove('active'));
for (const i of e.cells) boxes[i].classList.add('active');
clueItems.forEach((li, id) => li.classList.toggle('chosen', id === entryId(e)));
curEl.textContent = `${e.num} ${dirLabel(e)} — ${e.clue} (${e.w.length})`;
if (focus) {
const t = cell !== null ? cell : (e.cells.find((i) => !inputs[i].readOnly) ?? e.cells[0]);
if (document.activeElement === inputs[t]) setCurrent(t); else inputs[t].focus();
}
}
function toggleDirection(i) {
const other = entriesAt[i].find((e) => e !== selected);
if (other) choose(other, { cell: i });
}
function stepFrom(i, dy, dx) {
let y = Math.floor(i / n) + dy, x = (i % n) + dx;
while (y >= 0 && y < n && x >= 0 && x < n) {
if (inputs[y * n + x]) return y * n + x;
y += dy; x += dx;
}
return -1;
}
function clearCell(i) {
inputs[i].value = '';
state.cells[i] = '';
touch();
refreshLocks();
}
const ARROWS = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] };
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const i = y * n + x;
const box = document.createElement('div');
box.className = 'cell' + (b[y][x] ? '' : ' block');
boxes[i] = box;
if (b[y][x]) {
if (numbers[y][x]) {
const small = document.createElement('small');
small.textContent = numbers[y][x];
box.append(small);
}
const input = document.createElement('input');
input.type = 'text';
input.maxLength = 2; // 2 pour pouvoir remplacer une lettre ; seule la dernière est conservée
input.autocomplete = 'off';
input.autocapitalize = 'characters';
input.spellcheck = false;
input.setAttribute('autocorrect', 'off');
input.setAttribute('enterkeyhint', 'next');
input.setAttribute('aria-label', `Ligne ${y + 1}, colonne ${x + 1}`);
input.tabIndex = -1;
input.value = state.cells[i] || '';
inputs[i] = input;
input.addEventListener('focus', () => {
const list = entriesAt[i];
const pref = pendingDir || (list.includes(selected) ? null : selected.d);
let target = pref ? list.find((e) => e.d === pref) : null;
if (!target) target = list.includes(selected) ? selected : list[0];
pendingDir = null;
if (target !== selected) choose(target, { focus: false });
setCurrent(i);
});
input.addEventListener('pointerdown', () => { pointerWasFocused = document.activeElement === input; });
input.addEventListener('click', () => {
if (pointerWasFocused && entriesAt[i].length === 2) toggleDirection(i);
pointerWasFocused = false;
});
input.addEventListener('input', () => {
if (input.readOnly) { input.value = state.cells[i]; return; }
const v = input.value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase().replace(/[^A-Z]/g, '').slice(-1);
input.value = v;
state.cells[i] = v;
touch();
refreshLocks();
if (v) {
const ids = selected.cells;
const next = ids.slice(ids.indexOf(i) + 1).find((j) => !inputs[j].readOnly);
if (next !== undefined) inputs[next].focus();
}
});
input.addEventListener('keydown', (ev) => {
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
const dir = ARROWS[ev.key];
if (dir) {
const t = stepFrom(i, dir[0], dir[1]);
if (t >= 0) { ev.preventDefault(); pendingDir = dir[0] === 0 ? 'H' : 'V'; inputs[t].focus(); }
return;
}
if (ev.key === 'Backspace' || ev.key === 'Delete') {
ev.preventDefault();
if (!input.readOnly && input.value) { clearCell(i); return; }
if (ev.key === 'Backspace') {
const prev = selected.cells[selected.cells.indexOf(i) - 1];
if (prev !== undefined) inputs[prev].focus();
}
return;
}
if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); toggleDirection(i); }
});
box.append(input);
}
gridEl.append(box);
}
}
const firstInput = inputs.findIndex(Boolean);
inputs[firstInput].tabIndex = 0;
for (const e of order) {
const li = document.createElement('li');
li.value = e.num;
li.dataset.id = entryId(e);
li.tabIndex = 0;
li.setAttribute('role', 'button');
li.textContent = `${e.clue} (${e.w.length})`;
li.addEventListener('click', () => choose(e));
li.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); choose(e); } });
clueItems.set(entryId(e), li);
$(e.d === 'H' ? '.h' : '.v').append(li);
}
// ───────────────────────────── Logique de jeu ─────────────────────────────
function solvedSet() {
const s = new Set();
for (const e of entries) if (e.cells.every((pos, k) => state.cells[pos] === e.w[k])) s.add(entryId(e));
return s;
}
function refreshLocks() {
const solved = solvedSet();
const frozen = new Set();
for (const e of entries) if (solved.has(entryId(e))) e.cells.forEach((p) => frozen.add(p));
inputs.forEach((input, i) => {
if (!input) return;
input.readOnly = frozen.has(i);
input.setAttribute('aria-readonly', String(frozen.has(i)));
boxes[i].classList.toggle('locked', frozen.has(i));
});
clueItems.forEach((li, id) => li.classList.toggle('solved', solved.has(id)));
if (solved.size === entries.length && !finished()) finish();
renderControls();
return solved;
}
function finish() {
state.finalSeconds = scoreSeconds();
stats.history[date] = { sec: state.finalSeconds, hints: state.hints, checks: state.checks, n };
const keep = Object.keys(stats.history).sort().slice(-120);
stats.history = Object.fromEntries(keep.map((d) => [d, stats.history[d]]));
writeJSON(KEY_STATS, stats);
touch();
stopTimer();
renderResult();
renderStats();
statusEl.textContent = 'Bravo ! Tous les mots sont validés.';
}
function renderControls() {
const done = finished();
panel.querySelectorAll('[data-a="check"],[data-a="hint"]').forEach((el) => { el.disabled = done; });
}
function renderPenalties() {
penEl.textContent = `Indices : ${state.hints} · Vérifications : ${state.checks} · Pénalités : +${state.hints * HINT_PENALTY + state.checks * CHECK_PENALTY} s`;
}
function renderTimer() {
timerEl.textContent = '⏱ ' + formatTime(finished() ? state.finalSeconds : scoreSeconds());
}
function renderStats() {
const { current, best } = computeStreaks(stats.history, date);
const secs = Object.values(stats.history).map((r) => r.sec);
statsEl.textContent = secs.length
? `Grilles terminées : ${secs.length} · Série : ${current} 🔥 (record ${best}) · Meilleur temps : ${formatTime(Math.min(...secs))}`
: 'Aucune grille terminée pour l’instant — la série démarre à votre première victoire.';
}
function shareText() {
const head = `🧩 Crossword Daily JVC #${dayNum > 0 ? dayNum : date} · ${date}`;
if (finished()) {
const net = Math.floor(state.activeMs / 1000);
const { current } = computeStreaks(stats.history, date);
return `${head}\n${n}×${n} · ⏱ ${formatTime(state.finalSeconds)} (net ${formatTime(net)}, ${state.hints} indice(s), ${state.checks} vérif.)\n🔥 Série : ${current}`;
}
return `${head}\n${n}×${n} · en cours : ${solvedSet().size}/${entries.length} mots · ⏱ ${formatTime(scoreSeconds())}`;
}
function renderResult() {
if (!finished()) { resultEl.hidden = true; return; }
const net = Math.floor(state.activeMs / 1000);
resultEl.hidden = false;
resultEl.textContent = `Terminé en ${formatTime(state.finalSeconds)} : ${formatTime(net)} de jeu + ${state.hints * HINT_PENALTY + state.checks * CHECK_PENALTY} s de pénalités. Revenez demain pour le défi n°${dayNum + 1}.`;
}
function applyStateToUI() {
inputs.forEach((input, i) => { if (input) input.value = state.cells[i] || ''; });
refreshLocks();
renderTimer(); renderPenalties(); renderResult();
}
// ───────────────────────────── Chrono ─────────────────────────────
let timerId = null;
let tickAt = null;
let ticks = 0;
const counting = () => !panel.hidden && !document.hidden && !finished();
function tick() {
const now = Date.now();
if (tickAt !== null && counting()) state.activeMs += Math.min(now - tickAt, 5000); // plafond anti mise en veille
tickAt = now;
ticks++;
renderTimer();
if (ticks % 5 === 0) save();
if (ticks % 30 === 0) checkRollover();
}
function startTimer() { if (timerId === null) { tickAt = Date.now(); timerId = setInterval(tick, 1000); } }
function stopTimer() { if (timerId !== null) { clearInterval(timerId); timerId = null; } tickAt = null; save(); }
function checkRollover() {
if (parisDate() !== date) bannerEl.hidden = false;
}
function syncFromStorage() {
const raw = readJSON(KEY_DAY);
if (raw && Number.isFinite(raw.rev) && raw.rev > state.rev) { state = sanitize(raw); applyStateToUI(); }
stats = loadStats();
renderStats();
}
// ───────────────────────────── Actions ─────────────────────────────
let resetArmed = null;
const say = (msg) => { statusEl.textContent = msg; };
function removeFallback() { panel.querySelectorAll('.fallback').forEach((el) => el.remove()); }
panel.addEventListener('click', async (ev) => {
const btn = ev.target.closest('[data-a]');
if (!btn) return;
const a = btn.dataset.a;
removeFallback();
if (a === 'reload') { location.reload(); return; }
if (a === 'prev' || a === 'next') {
const k = order.indexOf(selected);
choose(order[(k + (a === 'next' ? 1 : order.length - 1)) % order.length]);
return;
}
if (a === 'check') {
if (finished()) return;
if (!state.cells.some(Boolean)) { say('Rien à vérifier pour l’instant.'); return; }
state.checks++;
touch();
const total = inputs.filter(Boolean).length;
const correct = inputs.filter((el, i) => el && state.cells[i] === solutionAt(i)).length;
renderPenalties(); renderTimer();
say(`${correct}/${total} lettres correctes (+${CHECK_PENALTY} s).`);
return;
}
if (a === 'hint') {
if (finished()) return;
const i = selected.cells.find((j) => state.cells[j] !== solutionAt(j));
if (i === undefined) { say('Ce mot est déjà correct.'); return; }
state.cells[i] = solutionAt(i);
inputs[i].value = state.cells[i];
state.hints++;
touch();
refreshLocks();
renderPenalties(); renderTimer();
say(`Une lettre révélée (+${HINT_PENALTY} s).`);
return;
}
if (a === 'share') {
const text = shareText();
try {
await navigator.clipboard.writeText(text);
say('Résultat copié : collez-le dans votre topic.');
} catch {
const ta = document.createElement('textarea');
ta.className = 'fallback';
ta.readOnly = true;
ta.value = text;
statusEl.after(ta);
ta.select();
say('Copie automatique impossible : copiez le texte ci-dessous (Ctrl+C).');
}
return;
}
if (a === 'reset') {
if (finished()) { say('Grille terminée : rien à effacer.'); return; }
if (!resetArmed) {
btn.textContent = 'Confirmer ?';
resetArmed = setTimeout(() => { btn.textContent = 'Effacer mes erreurs'; resetArmed = null; }, 4000);
return;
}
clearTimeout(resetArmed); resetArmed = null;
btn.textContent = 'Effacer mes erreurs';
// Seules les cases non validées sont effacées ; chrono, indices et vérifications sont conservés.
const solved = solvedSet();
const keep = new Set();
for (const e of entries) if (solved.has(entryId(e))) e.cells.forEach((p) => keep.add(p));
state.cells = state.cells.map((c, i) => (keep.has(i) ? c : ''));
inputs.forEach((input, i) => { if (input) input.value = state.cells[i]; });
touch();
refreshLocks();
say('Cases non validées effacées (chrono et pénalités conservés).');
}
});
// ───────────────────────────── Ouverture / fermeture ─────────────────────────────
function open(v) {
panel.hidden = !v;
launch.setAttribute('aria-expanded', String(v));
if (v) {
checkRollover();
syncFromStorage();
if (!storageOk) say('Stockage local indisponible : votre progression ne sera pas sauvegardée.');
choose(selected);
startTimer();
} else {
stopTimer();
launch.focus();
}
}
launch.addEventListener('click', () => open(panel.hidden));
$('.close').addEventListener('click', () => open(false));
// Les frappes dans le panneau ne doivent pas déclencher d'éventuels raccourcis d'autres scripts.
panel.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') { ev.preventDefault(); open(false); }
ev.stopPropagation();
});
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !panel.hidden) open(false); });
document.addEventListener('visibilitychange', () => {
if (document.hidden) { save(); tickAt = null; } else { tickAt = Date.now(); syncFromStorage(); }
});
window.addEventListener('pagehide', save);
window.addEventListener('storage', (ev) => { if (ev.key === KEY_DAY || ev.key === KEY_STATS) syncFromStorage(); });
// ───────────────────────────── Intégration au titre du forum ─────────────────────────────
const t0 = Date.now();
const titleText = (el) => {
let t = '';
el.childNodes.forEach((nd) => { if (nd !== launch) t += nd.textContent; });
return t.replace(/\s+/g, ' ').trim();
};
function findTitle() {
const cands = [...document.querySelectorAll('h1,h2,[role="heading"]')]
.filter((el) => !el.closest('#cdj-panel') && el.getClientRects().length > 0 && /^Forum\s+\S/i.test(titleText(el)));
return cands.find((el) => el.tagName === 'H1') || cands[0] || null;
}
function attach() {
const t = findTitle();
if (!t) return false;
launch.classList.remove('floating');
if (launch.parentElement !== t) t.appendChild(launch);
log('bouton ancré sur', t);
return true;
}
// Dernier recours (uniquement sur une liste de sujets) si le titre n'est pas trouvé : bouton flottant.
function floatFallback() {
if (launch.isConnected || Date.now() - t0 < 8000 || !/^\/forums\/0-/.test(location.pathname)) return;
launch.classList.add('floating');
document.body.append(launch);
console.warn('[CDJ] Titre du forum introuvable : bouton flottant utilisé.');
}
function ensure() {
if (!panel.isConnected && document.body) document.body.append(panel);
if (launch.isConnected && !launch.classList.contains('floating')) return;
if (!attach()) floatFallback();
}
let raf = 0;
const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; ensure(); }); };
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
setTimeout(ensure, 8200);
// ───────────────────────────── Démarrage ─────────────────────────────
choose(selected, { focus: false });
refreshLocks();
renderTimer(); renderPenalties(); renderResult(); renderStats();
ensure();
log('prêt', { date, n, mots: entries.length, version: VERSION });
})();