openvibers/openvibe.live Commits
Daily Secret v2: one fair clue per key (AI clues aligned to the code, deterministic clue bank as backstop), key slots with live feedback, on-screen keypad for phones, stuck-reveal after 2 wrong tries (never the last key), first-crackers, theme-safe panel that no longer clips the clues
Changes
@@@ -1363,24 +1363,52 @@ main#app { position: relative; z-index: 1; }.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }/* ── Daily easter-egg hero widget ─────────────────────────────── */--.hero-egg { max-width: 460px; margin: 0 auto 22px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent); background: color-mix(in srgb, var(--accent) 8%, rgba(0,0,0,0.35)); overflow: hidden; text-align: left; }--.hero-egg-head { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: none; border: 0; cursor: pointer; color: var(--text-primary); }++.hero-egg { max-width: 520px; margin: 0 auto 22px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); background: var(--bg-card); box-shadow: 0 14px 40px rgba(0,0,0,0.3); text-align: left; overflow: hidden; }++.hero-egg-head { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: none; border: 0; cursor: pointer; color: var(--text-primary); font: inherit; }.hero-egg-icon { font-size: 1.3rem; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4)); animation: eggWobble 3.5s ease-in-out infinite; }--@keyframes eggWobble { 0%,100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }++@keyframes eggWobble { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }.hero-egg-titles { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; flex: 1; }.hero-egg-kicker { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }--.hero-egg-title { font-size: 0.95rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }--.hero-egg-status { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); background: rgba(255,255,255,0.06); padding: 3px 9px; border-radius: 999px; }++.hero-egg-title { font-size: 0.95rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; color: var(--text-primary); }++.hero-egg-status { font-size: 0.72rem; font-weight: 700; color: var(--text-secondary); background: var(--bg-tertiary); padding: 3px 9px; border-radius: 999px; white-space: nowrap; }.hero-egg-status.solved { color: #06d6a0; background: color-mix(in srgb, #06d6a0 18%, transparent); }.hero-egg-chev { color: var(--text-muted); transition: transform .2s ease; }.hero-egg.open .hero-egg-chev { transform: rotate(180deg); }--.hero-egg-body { max-height: 0; overflow: hidden; opacity: 0; transition: max-height .35s ease, opacity .3s ease, padding .3s ease; padding: 0 14px; }--.hero-egg.open .hero-egg-body { max-height: 340px; opacity: 1; padding: 0 14px 14px; }--.hero-egg-intro { font-size: 0.78rem; color: var(--text-secondary); margin: 0 0 10px; }--.hero-egg-hints { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 7px; }--.hero-egg-hints li { position: relative; padding-left: 22px; font-size: 0.82rem; color: var(--text-primary); font-style: italic; }--.hero-egg-hints li::before { content: '\f0eb'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; left: 0; top: 1px; color: var(--accent); font-style: normal; font-size: 0.78rem; }--.hero-egg-meta { margin-top: 11px; font-size: 0.72rem; color: var(--text-muted); }++/* Body opens to its real height (the old 340px cap clipped the third clue) */++.hero-egg-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .35s ease; }++.hero-egg.open .hero-egg-body { grid-template-rows: 1fr; }++.hero-egg-inner { min-height: 0; overflow: hidden; padding: 0 14px; }++.hero-egg.open .hero-egg-inner { padding: 0 14px 14px; }++.hero-egg-intro { font-size: 0.78rem; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.45; }++.hero-egg-slots { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; }++.egg-slot { width: 38px; height: 42px; border-radius: 10px; display: grid; place-items: center; font-size: 1.15rem; font-weight: 900; color: var(--text-primary); background: var(--bg-tertiary); border: 1px solid var(--border); transition: transform .15s, border-color .15s, background .15s; }++.egg-slot.is-filled { border-color: color-mix(in srgb, var(--accent) 70%, transparent); background: color-mix(in srgb, var(--accent) 16%, var(--bg-tertiary)); animation: eggSlotIn .18s cubic-bezier(.2,1.5,.3,1); }++.egg-slot.is-next { border-style: dashed; border-color: color-mix(in srgb, var(--accent) 60%, transparent); }++.hero-egg-slots.is-wrong .egg-slot { border-color: #ff5a5f; background: color-mix(in srgb, #ff5a5f 18%, var(--bg-tertiary)); animation: eggShakeSlot .45s; }++.hero-egg-slots.is-right .egg-slot, .hero-egg-slots.is-solved .egg-slot { border-color: #06d6a0; background: color-mix(in srgb, #06d6a0 20%, var(--bg-tertiary)); }++@keyframes eggSlotIn { from { transform: scale(0.7); } to { transform: scale(1); } }++@keyframes eggShakeSlot { 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(3px); } }++.hero-egg-clues { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; counter-reset: clue; }++.hero-egg-clues li { display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 8px; font-size: 0.84rem; color: var(--text-primary); padding: 6px 8px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); }++.egg-clue-n { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 0.7rem; font-weight: 800; color: #fff; background: var(--accent); }++.egg-clue-text { font-style: italic; line-height: 1.35; }++.egg-clue-key { font-weight: 900; font-size: 0.95rem; color: #06d6a0; background: color-mix(in srgb, #06d6a0 15%, transparent); border-radius: 8px; padding: 2px 9px; }++.hero-egg-clues li.is-revealed { border-color: color-mix(in srgb, #06d6a0 45%, var(--border)); }++.hero-egg-pad { display: grid; gap: 6px; margin-bottom: 10px; }++.hero-egg-pad-arrows { display: flex; gap: 6px; flex-wrap: wrap; }++.hero-egg-pad-letters { display: grid; grid-template-columns: repeat(13, 1fr); gap: 4px; }++.egg-key { min-width: 34px; height: 34px; padding: 0 8px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-weight: 800; cursor: pointer; transition: transform .1s, background .15s, border-color .15s; }++.egg-key:hover { border-color: var(--accent); }++.egg-key:active { transform: scale(0.92); background: color-mix(in srgb, var(--accent) 25%, var(--bg-tertiary)); }++.egg-key--letter { min-width: 0; height: 30px; padding: 0; font-size: 0.8rem; text-transform: uppercase; }++.egg-key--ctl { margin-left: auto; color: var(--text-secondary); }++.egg-key--ctl + .egg-key--ctl { margin-left: 0; }++.hero-egg-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }++.hero-egg-reveal { border: 1px solid color-mix(in srgb, #fbbf24 45%, var(--border)); background: color-mix(in srgb, #fbbf24 10%, transparent); color: var(--text-primary); border-radius: 999px; padding: 5px 11px; font: inherit; font-size: 0.76rem; font-weight: 700; cursor: pointer; }++.hero-egg-reveal:disabled { opacity: 0.6; cursor: default; }++.hero-egg-reveal:not(:disabled):hover { border-color: #fbbf24; }++.hero-egg-meta { font-size: 0.72rem; color: var(--text-muted); }++@media (max-width: 480px) { .hero-egg-pad-letters { grid-template-columns: repeat(9, 1fr); } .egg-slot { width: 32px; height: 38px; } }/* Celebration effects */.egg-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 100000; overflow: hidden; }@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=160">++ <link rel="stylesheet" href="/css/style.css?v=161"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2">@@@ -237,9 +237,26 @@<i class="fa-solid fa-chevron-down hero-egg-chev"></i></button><div class="hero-egg-body">-- <p class="hero-egg-intro">A fresh AI-made secret drops daily. Decode the hints into a key sequence, then type it anywhere on the site to crack it.</p>-- <ul class="hero-egg-hints" id="hero-egg-hints"></ul>-- <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-reset"></span></div>++ <div class="hero-egg-inner">++ <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>++ <div class="hero-egg-slots" id="hero-egg-slots" aria-label="Your key sequence"></div>++ <ol class="hero-egg-clues" id="hero-egg-clues"></ol>++ <div class="hero-egg-pad" id="hero-egg-pad">++ <div class="hero-egg-pad-arrows">++ <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>++ <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>++ <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>++ <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>++ <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>++ <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>++ </div>++ <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>++ </div>++ <div class="hero-egg-foot">++ <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>++ <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>++ </div>++ </div></div></div><div class="hero-cta">@@@ -5052,7 +5069,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script><script src="/live-notify.js?v=3" defer></script>--<script src="/js/app.js?v=138" defer></script>++<script src="/js/app.js?v=139" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -1685,30 +1685,86 @@ function openHomeAbout() {// ── Daily AI easter egg ─────────────────────────────────────────────────────let _egg = null, _eggBuf = [], _eggSolved = false, _eggSubmitTimer = null, _eggKeysWired = false;const _EGG_ARROW = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };++const _EGG_GLYPH = { up: '↑', down: '↓', left: '←', right: '→' };++let _eggRevealed = []; // [{index, token}] revealed for this solver today++let _eggFails = 0, _eggBusy = false;async function loadHeroEgg() {const el = document.getElementById('hero-egg');if (!el) return;let data; try { data = await api('/easter-egg/daily'); } catch { return; }const egg = data && data.egg;if (!egg) { el.style.display = 'none'; return; }-- _egg = egg; _eggSolved = !!egg.solved;++ _egg = egg; _eggSolved = !!egg.solved; _eggBuf = []; _eggRevealed = egg.revealed || []; _eggFails = egg.fails || 0;el.style.display = '';const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };set('hero-egg-title', egg.title || 'The Daily Secret');-- const hints = document.getElementById('hero-egg-hints');-- if (hints) hints.innerHTML = (egg.hints || []).length ? egg.hints.map(h => `<li>${esc(h)}</li>`).join('') : '<li>No hints today — go on instinct.</li>';-- set('hero-egg-count', `${egg.foundCount || 0} cracked it today`);-- const reset = document.getElementById('hero-egg-reset');-- if (reset) reset.textContent = egg.nextResetAt ? ` · resets ${_eggResetLabel(egg.nextResetAt)}` : '';++ // Letter pad (built once)++ const letters = document.getElementById('hero-egg-letters');++ if (letters && !letters.childElementCount) letters.innerHTML = 'abcdefghijklmnopqrstuvwxyz'.split('').map(l => `<button type="button" class="egg-key egg-key--letter" data-tok="${l}">${l}</button>`).join('');++ const pad = document.getElementById('hero-egg-pad');++ if (pad && !pad.dataset.wired) {++ pad.dataset.wired = '1';++ pad.addEventListener('click', (e) => {++ const b = e.target.closest('button'); if (!b) return;++ if (b.dataset.act === 'back') { _eggBuf.pop(); _renderEggSlots(); return; }++ if (b.dataset.act === 'clear') { _eggBuf = []; _renderEggSlots(); return; }++ if (b.dataset.tok) _eggPush(b.dataset.tok);++ });++ }++ _renderEggClues();++ _renderEggSlots();++ _renderEggMeta();_renderEggStatus();_wireEggKeys();}function _eggResetLabel(ts) { const h = Math.round((ts - Date.now()) / 3600000); return h <= 1 ? 'soon' : `in ${h}h`; }function _renderEggStatus() {const s = document.getElementById('hero-egg-status'); if (!s) return;-- s.textContent = _eggSolved ? '✓ Solved' : `${_egg ? _egg.codeLength : ''} keys`;++ s.textContent = _eggSolved ? '✓ Cracked' : `${_egg ? _egg.codeLength : ''} keys`;s.className = 'hero-egg-status' + (_eggSolved ? ' solved' : '');}++function _renderEggMeta() {++ if (!_egg) return;++ const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };++ set('hero-egg-count', `${_egg.foundCount || 0} cracked it today`);++ set('hero-egg-first', (_egg.firstSolvers || []).length ? ` · first: ${_egg.firstSolvers.slice(0, 3).join(', ')}` : '');++ set('hero-egg-reset', _egg.nextResetAt ? ` · new secret ${_eggResetLabel(_egg.nextResetAt)}` : '');++ const rb = document.getElementById('hero-egg-reveal');++ if (rb) {++ const left = Math.max(0, (_egg.revealAfterFails || 2) - _eggFails);++ const done = _eggRevealed.length >= Math.max(0, _egg.codeLength - 1);++ rb.style.display = _eggSolved ? 'none' : '';++ rb.disabled = done || left > 0;++ rb.innerHTML = done ? '<i class="fa-solid fa-lightbulb"></i> The last key is yours'++ : left > 0 ? `<i class="fa-regular fa-lightbulb"></i> Stuck? ${left} more wrong tr${left === 1 ? 'y' : 'ies'} unlocks a reveal`++ : '<i class="fa-solid fa-lightbulb"></i> Stuck? Reveal the next key';++ }++}++function _renderEggClues() {++ const ol = document.getElementById('hero-egg-clues'); if (!ol || !_egg) return;++ const clues = _egg.clues || _egg.hints || [];++ const rev = new Map(_eggRevealed.map(r => [r.index, r.token]));++ ol.innerHTML = clues.map((c, i) => `<li class="${rev.has(i) ? 'is-revealed' : ''}"><span class="egg-clue-n">${i + 1}</span><span class="egg-clue-text">${esc(c)}</span>${rev.has(i) ? `<span class="egg-clue-key" title="Revealed">${esc(_EGG_GLYPH[rev.get(i)] || rev.get(i).toUpperCase())}</span>` : ''}</li>`).join('')++ || '<li><span class="egg-clue-text">No clues today — go on instinct.</span></li>';++}++function _renderEggSlots(state) {++ const host = document.getElementById('hero-egg-slots'); if (!host || !_egg) return;++ const n = _egg.codeLength || 0;++ let html = '';++ for (let i = 0; i < n; i++) {++ const t = _eggBuf[i];++ html += `<span class="egg-slot ${t ? 'is-filled' : ''} ${i === _eggBuf.length ? 'is-next' : ''}">${t ? esc(_EGG_GLYPH[t] || t.toUpperCase()) : ''}</span>`;++ }++ host.innerHTML = html;++ host.className = 'hero-egg-slots' + (state ? ` is-${state}` : '') + (_eggSolved ? ' is-solved' : '');++}++function _eggPush(tok) {++ if (_eggSolved || !_egg || _eggBusy) return;++ _eggBuf.push(tok);++ if (_eggBuf.length > _egg.codeLength) _eggBuf = _eggBuf.slice(-_egg.codeLength);++ _renderEggSlots();++ if (_eggBuf.length === _egg.codeLength) { clearTimeout(_eggSubmitTimer); _eggSubmitTimer = setTimeout(_submitEgg, 200); }++}function toggleHeroEgg() { document.getElementById('hero-egg')?.classList.toggle('open'); }function _wireEggKeys() {if (_eggKeysWired) return;@@@ -1716,23 +1772,39 @@ function _wireEggKeys() {document.addEventListener('keydown', (e) => {if (_eggSolved || !_egg) return;const t = e.target;-- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;++ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;++ if (e.ctrlKey || e.metaKey || e.altKey) return;++ if (e.key === 'Backspace') { _eggBuf.pop(); _renderEggSlots(); return; }let tok = _EGG_ARROW[e.key];if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();if (!tok) return;-- _eggBuf.push(tok);-- if (_eggBuf.length > _egg.codeLength) _eggBuf = _eggBuf.slice(-_egg.codeLength);-- if (_eggBuf.length === _egg.codeLength) { clearTimeout(_eggSubmitTimer); _eggSubmitTimer = setTimeout(_submitEgg, 220); }++ if (_EGG_ARROW[e.key]) e.preventDefault(); // don't scroll the page on arrows++ _eggPush(tok);++ // Typing counts as engagement: pop the panel open so people see the keys land.++ const el = document.getElementById('hero-egg'); if (el && !el.classList.contains('open') && _eggBuf.length === 1) el.classList.add('open');});}async function _submitEgg() {-- if (_eggSolved || !_egg || _eggBuf.length < _egg.codeLength) return;-- let res; try { res = await api('/easter-egg/solve', { method: 'POST', body: { sequence: _eggBuf.slice() } }); } catch { return; }++ if (_eggSolved || !_egg || _eggBuf.length < _egg.codeLength || _eggBusy) return;++ _eggBusy = true;++ let res; try { res = await api('/easter-egg/solve', { method: 'POST', body: { sequence: _eggBuf.slice() } }); } catch { _eggBusy = false; return; }++ _eggBusy = false;if (res && res.solved) {-- _eggSolved = true; _renderEggStatus();-- const c = document.getElementById('hero-egg-count'); if (c && res.foundCount != null) c.textContent = `${res.foundCount} cracked it today`;++ _eggSolved = true; _renderEggStatus(); _renderEggSlots('right');++ if (res.foundCount != null) _egg.foundCount = res.foundCount;++ _renderEggMeta();_celebrateEgg(res.egg || {});++ return;}++ _eggFails = (res && res.fails) || (_eggFails + 1);++ _renderEggSlots('wrong');++ _renderEggMeta();++ setTimeout(() => { _eggBuf = []; _renderEggSlots(); }, 750);++}++async function _eggReveal() {++ if (_eggSolved || !_egg) return;++ let out; try { out = await api('/easter-egg/reveal', { method: 'POST', body: {} }); } catch (e) { try { toast((e && e.message) || 'Not yet', 'info'); } catch { /* */ } return; }++ if (out && out.revealed) { _eggRevealed = out.revealed; _renderEggClues(); _renderEggMeta(); }}function _celebrateEgg(egg) {try { toast(egg.reward || "You cracked today's secret! 🎉", 'success'); } catch { /* */ }@@@ -26,9 +26,12 @@ function _due() { const p = _load(); return !p.date || p.date !== _today() || !Afunction _seededCode() {let seed = 0; for (const ch of _today()) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };-- const len = 6 + Math.floor(rnd() * 3);++ const len = 5 + Math.floor(rnd() * 3);const code = [];-- for (let i = 0; i < len; i++) code.push(TOKENS[Math.floor(rnd() * TOKENS.length)]);++ // Konami feel: roughly half arrows, half letters, mixed.++ for (let i = 0; i < len; i++) code.push(rnd() < 0.55 ? DIRS[Math.floor(rnd() * DIRS.length)] : LETTERS[Math.floor(rnd() * LETTERS.length)]);++ if (!code.some(t => DIRS.includes(t))) code[0] = DIRS[Math.floor(rnd() * DIRS.length)];++ if (!code.some(t => !DIRS.includes(t))) code[len - 1] = LETTERS[Math.floor(rnd() * LETTERS.length)];return code;}function _sanitizeCode(arr) {@@@ -36,28 +39,57 @@ function _sanitizeCode(arr) {const code = arr.map(t => String(t || '').toLowerCase().trim()).map(t => ({ arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right' }[t] || t)).filter(t => TOKENS.includes(t));-- return (code.length >= 5 && code.length <= 12) ? code : null;++ return (code.length >= 4 && code.length <= 9) ? code : null;++}++++// ── Clue bank: a fair, readable clue for every token (used for the no-AI fallback and to++// patch any AI clue that is missing or does not line up with the code) ─────────────────++const DIR_CLUES = {++ up: ['toward the sky', 'the way a balloon goes', 'north on the compass', 'where the ceiling lives', 'the arrow that climbs'],++ down: ['toward the floor', 'the way rain falls', 'south on the compass', 'where roots grow', 'the arrow that sinks'],++ left: ['the side your heart is on', 'west on the compass', 'the arrow that points at the past', 'port side, sailor', 'where the sun sets'],++ right: ['the side most people write with', 'east on the compass', 'the arrow that points at the future', 'starboard, sailor', 'where the sun rises'],++};++const LETTER_WORDS = { a: 'arena', b: 'broadcast', c: 'clip', d: 'donate', e: 'emote', f: 'follow', g: 'goose', h: 'hype', i: 'irl', j: 'jump', k: 'keyboard', l: 'latency', m: 'mic', n: 'network', o: 'overlay', p: 'paste', q: 'queue', r: 'robot', s: 'stream', t: 'tts', u: 'upload', v: 'vibe', w: 'webrtc', x: 'xp', y: 'yap', z: 'zoom' };++const ORD = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth'];++function _clueFor(tok, i, rnd) {++ if (DIR_CLUES[tok]) { const list = DIR_CLUES[tok]; return list[(i + Math.floor(rnd() * list.length)) % list.length]; }++ const w = LETTER_WORDS[tok] || tok;++ const forms = [`the letter that starts "${w}"`, `"${w}" begins with it`, `the ${ORD[0]} letter of "${w}"`];++ return forms[Math.floor(rnd() * forms.length)];++}++function _rngFor(seedStr) { let seed = 7; for (const ch of seedStr) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0; return () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; }++/** One clue per token, in order — AI clues kept when they line up, bank clues fill the gaps. */++function _alignClues(code, aiClues) {++ const rnd = _rngFor(_today() + code.join(','));++ const out = [];++ for (let i = 0; i < code.length; i++) {++ const c = Array.isArray(aiClues) && aiClues[i] ? String(aiClues[i]).replace(/\s+/g, ' ').trim().slice(0, 110) : '';++ out.push(c || _clueFor(code[i], i, rnd));++ }++ return out;}async function _generate() {let vibe = '';try { const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }--if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {-- const prompt = `Invent today's secret "Konami-style" easter egg for OpenVibe.Live, a hobbyist live-streaming site.${vibe ? `\nToday's community vibe (for flavour): ${vibe}` : ''}--++ const prompt = `Invent today's secret "Konami-style" code for OpenVibe.Live, a hobbyist live-streaming site.${vibe ? `\nToday's community vibe (for flavour only): ${vibe}` : ''}Return STRICT JSON only, nothing else:{"title": "<punchy name for today's secret, 2-5 words>",-- "code": [<6 to 9 tokens IN ORDER, each exactly one of: "up","down","left","right", or a single lowercase letter a-z>],-- "hints": ["<hint 1>", "<hint 2>", "<hint 3>"],++ "code": [<5 to 7 tokens IN ORDER, each exactly one of: "up","down","left","right", or a single lowercase letter a-z; use at least 2 arrows and at least 1 letter>],++ "clues": [<EXACTLY one clue per token, same order, each ≤ 90 chars>],"effect": "<one of: confetti, fireworks, matrix, rainbow, shake>","reward": "<a short, fun congratulatory line shown when a user cracks it>"}----The hints, taken together and in order, must cryptically ENCODE the exact code sequence (use riddles/wordplay for directions and letters — e.g. "twice toward the heavens" = up,up) WITHOUT ever listing the tokens literally. Make it clever but genuinely figure-out-able. Keep it playful and a little unhinged.`;++CLUE RULES (this is a game people must actually be able to win):++- Clue i describes token i and ONLY token i. Never list the token literally, but make it fair: a normal person should get it in one or two guesses.++- Arrow clues use direction imagery ("where the sun rises" = right, "toward the sky" = up, "the way rain falls" = down, "the side your heart is on" = left).++- Letter clues name a common word the letter starts ("the letter that starts 'goose'" = g), or a famous initial.++- Keep the flavour playful and on-theme, but clarity beats cleverness.`;try {-- const text = await ai.summarizeText(prompt, 500, 'easter_egg');++ const text = await ai.summarizeText(prompt, 600, 'easter_egg');const m = text && text.match(/\{[\s\S]*\}/);if (m) {const j = JSON.parse(m[0]);@@@ -66,7 +98,7 @@ The hints, taken together and in order, must cryptically ENCODE the exact code sreturn {title: String(j.title || 'The Daily Secret').replace(/\s+/g, ' ').trim().slice(0, 60),code,-- hints: (Array.isArray(j.hints) ? j.hints : []).map(h => String(h || '').trim().slice(0, 160)).filter(Boolean).slice(0, 4),++ clues: _alignClues(code, j.clues || j.hints),effect: EFFECTS.includes(j.effect) ? j.effect : 'confetti',reward: String(j.reward || "You cracked today's secret! 🎉").trim().slice(0, 200),ai: true,@@@ -75,21 +107,15 @@ The hints, taken together and in order, must cryptically ENCODE the exact code s}} catch { /* fall through */ }}-- // Fallback++ // Fallback: deterministic daily code with bank clues — always solvable.const code = _seededCode();-- const dirWord = { up: 'skyward', down: 'to the depths', left: 'to the west', right: 'to the east' };return {title: 'The Daily Secret',code,-- hints: [-- 'The old gamers knew: four winds and a few good letters open the way.',-- 'Watch the arrows and whisper the letters, in the order the day decided.',-- 'No shame in guessing — the brave stumble onto it.',-- ],++ clues: _alignClues(code, null),effect: EFFECTS[code.length % EFFECTS.length],reward: "You cracked today's secret! 🎉",ai: false,-- void: dirWord,};}@@@ -111,15 +137,44 @@ function getPublic() {if (!p.code || p.date !== _today()) return null;const now = new Date();const nextReset = new Date(now); nextReset.setHours(24, 0, 0, 0);++ const clues = Array.isArray(p.clues) && p.clues.length === p.code.length ? p.clues : _alignClues(p.code, p.clues || p.hints);return {date: p.date,title: p.title || 'The Daily Secret',-- hints: p.hints || [],++ clues,++ hints: clues, // back-compat for older clientscodeLength: p.code.length,effect: p.effect || 'confetti',nextResetAt: nextReset.getTime(),++ ai: !!p.ai,};}++++// ── "Stuck?" reveals: after a couple of wrong attempts a solver may reveal keys one at a time++// (never the last one) — enough to keep it fun, not enough to make typing pointless. Per-day,++// per-solver, in memory (a restart just makes people try twice more).++const _fails = new Map(); // solverKey → { date, n, revealed:Set }++function _rec(solverKey) {++ const d = _today(); let r = _fails.get(solverKey);++ if (!r || r.date !== d) { r = { date: d, n: 0, revealed: new Set() }; _fails.set(solverKey, r); }++ if (_fails.size > 5000) { const k = _fails.keys().next().value; _fails.delete(k); }++ return r;++}++function noteFail(solverKey) { const r = _rec(solverKey); r.n++; return r.n; }++const REVEAL_AFTER_FAILS = 2;++function reveal(solverKey, index) {++ const p = _load();++ if (!p.code || p.date !== _today()) return { error: 'No secret today' };++ const r = _rec(solverKey);++ const i = Number.isInteger(index) ? index : [...Array(p.code.length).keys()].find(k => !r.revealed.has(k));++ if (i == null || i < 0 || i >= p.code.length) return { error: 'Nothing left to reveal' };++ if (i === p.code.length - 1 && !r.revealed.has(i)) return { error: 'The last key is yours to find', last: true };++ if (r.n < REVEAL_AFTER_FAILS && !r.revealed.has(i)) return { error: `Try ${REVEAL_AFTER_FAILS - r.n} more time${REVEAL_AFTER_FAILS - r.n === 1 ? '' : 's'} first`, needFails: REVEAL_AFTER_FAILS - r.n };++ r.revealed.add(i);++ return { index: i, token: p.code[i], revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) };++}++function revealedFor(solverKey) { const p = _load(); const r = _fails.get(solverKey); if (!p.code || !r || r.date !== _today()) return { fails: r ? r.n : 0, revealed: [] }; return { fails: r.n, revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) }; }++// Validate an attempt against today's secret code (server-side only).function checkSolution(sequence) {const p = _load();@@@ -138,9 +193,9 @@ function start() {console.log('[EasterEgg] Daily easter-egg job started');}--module.exports = { start, tick, getPublic, checkSolution };++module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS };// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`if (require.main === module) {-- tick({ force: true }).then(() => { const p = _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, hints: p.hints, effect: p.effect })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });++ tick({ force: true }).then(() => { const p = _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });}@@@ -17,11 +17,18 @@ router.get('/daily', optionalAuth, (req, res) => {try {const pub = eggJob.getPublic();if (!pub) return res.json({ egg: null });++ const mine = eggJob.revealedFor(_solverKey(req));++ let firstSolvers = [];++ try { firstSolvers = (db.all('SELECT u.username, u.display_name FROM easter_egg_solves s JOIN users u ON u.id = s.user_id WHERE s.egg_date = ? ORDER BY s.rowid ASC LIMIT 3', [pub.date]) || []).map(r => r.display_name || r.username); } catch { firstSolvers = []; }res.json({egg: {...pub,foundCount: db.countEasterEggSolves(pub.date),solved: db.hasSolvedEasterEgg(pub.date, _solverKey(req)),++ firstSolvers,++ fails: mine.fails,++ revealed: mine.revealed,++ revealAfterFails: eggJob.REVEAL_AFTER_FAILS,},});} catch (err) {@@@ -44,7 +51,7 @@ router.post('/solve', optionalAuth, (req, res) => {if (!seq) return res.status(400).json({ error: 'sequence required' });const result = eggJob.checkSolution(seq);-- if (!result || !result.ok) return res.json({ solved: false });++ if (!result || !result.ok) { const fails = eggJob.noteFail(_solverKey(req)); return res.json({ solved: false, fails, canReveal: fails >= eggJob.REVEAL_AFTER_FAILS }); }const firstTime = db.recordEasterEggSolve(result.egg.date, _solverKey(req), (req.user && req.user.id) || null);res.json({@@@ -58,4 +65,14 @@ router.post('/solve', optionalAuth, (req, res) => {}});++// "Stuck?" — reveal one key (after a couple of wrong tries; never the last key).++router.post('/reveal', optionalAuth, (req, res) => {++ try {++ const idx = req.body && Number.isInteger(req.body.index) ? req.body.index : null;++ const out = eggJob.reveal(_solverKey(req), idx);++ if (out.error) return res.status(out.needFails ? 425 : 403).json(out);++ res.json(out);++ } catch (err) { res.status(500).json({ error: 'Failed to reveal' }); }++});++module.exports = router;
@@ -1363,24 +1363,52 @@ main#app { position: relative; z-index: 1; }
@@ -1363,24 +1363,52 @@ main#app { position: relative; z-index: 1; }
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
/* ── Daily easter-egg hero widget ─────────────────────────────── */
/* ── Daily easter-egg hero widget ─────────────────────────────── */
-.hero-egg { max-width: 460px; margin: 0 auto 22px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent); background: color-mix(in srgb, var(--accent) 8%, rgba(0,0,0,0.35)); overflow: hidden; text-align: left; }
-.hero-egg-head { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: none; border: 0; cursor: pointer; color: var(--text-primary); }
+.hero-egg { max-width: 520px; margin: 0 auto 22px; border-radius: 16px; border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); background: var(--bg-card); box-shadow: 0 14px 40px rgba(0,0,0,0.3); text-align: left; overflow: hidden; }
+.hero-egg-head { width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: none; border: 0; cursor: pointer; color: var(--text-primary); font: inherit; }
.hero-egg-icon { font-size: 1.3rem; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4)); animation: eggWobble 3.5s ease-in-out infinite; }
.hero-egg-icon { font-size: 1.3rem; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4)); animation: eggWobble 3.5s ease-in-out infinite; }
-@keyframes eggWobble { 0%,100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
+@keyframes eggWobble { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }
.hero-egg-titles { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; flex: 1; }
.hero-egg-titles { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; flex: 1; }
.hero-egg-kicker { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }
.hero-egg-kicker { font-size: 0.62rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }
-.hero-egg-title { font-size: 0.95rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
-.hero-egg-status { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); background: rgba(255,255,255,0.06); padding: 3px 9px; border-radius: 999px; }
+.hero-egg-title { font-size: 0.95rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; color: var(--text-primary); }
+.hero-egg-status { font-size: 0.72rem; font-weight: 700; color: var(--text-secondary); background: var(--bg-tertiary); padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
.hero-egg-status.solved { color: #06d6a0; background: color-mix(in srgb, #06d6a0 18%, transparent); }
.hero-egg-status.solved { color: #06d6a0; background: color-mix(in srgb, #06d6a0 18%, transparent); }
.hero-egg-chev { color: var(--text-muted); transition: transform .2s ease; }
.hero-egg-chev { color: var(--text-muted); transition: transform .2s ease; }
.hero-egg.open .hero-egg-chev { transform: rotate(180deg); }
.hero-egg.open .hero-egg-chev { transform: rotate(180deg); }
-.hero-egg-body { max-height: 0; overflow: hidden; opacity: 0; transition: max-height .35s ease, opacity .3s ease, padding .3s ease; padding: 0 14px; }
-.hero-egg.open .hero-egg-body { max-height: 340px; opacity: 1; padding: 0 14px 14px; }
-.hero-egg-intro { font-size: 0.78rem; color: var(--text-secondary); margin: 0 0 10px; }
-.hero-egg-hints { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 7px; }
-.hero-egg-hints li { position: relative; padding-left: 22px; font-size: 0.82rem; color: var(--text-primary); font-style: italic; }
-.hero-egg-hints li::before { content: '\f0eb'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; left: 0; top: 1px; color: var(--accent); font-style: normal; font-size: 0.78rem; }
-.hero-egg-meta { margin-top: 11px; font-size: 0.72rem; color: var(--text-muted); }
+/* Body opens to its real height (the old 340px cap clipped the third clue) */
+.hero-egg-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .35s ease; }
+.hero-egg.open .hero-egg-body { grid-template-rows: 1fr; }
+.hero-egg-inner { min-height: 0; overflow: hidden; padding: 0 14px; }
+.hero-egg.open .hero-egg-inner { padding: 0 14px 14px; }
+.hero-egg-intro { font-size: 0.78rem; color: var(--text-secondary); margin: 0 0 10px; line-height: 1.45; }
+.hero-egg-slots { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; }
+.egg-slot { width: 38px; height: 42px; border-radius: 10px; display: grid; place-items: center; font-size: 1.15rem; font-weight: 900; color: var(--text-primary); background: var(--bg-tertiary); border: 1px solid var(--border); transition: transform .15s, border-color .15s, background .15s; }
+.egg-slot.is-filled { border-color: color-mix(in srgb, var(--accent) 70%, transparent); background: color-mix(in srgb, var(--accent) 16%, var(--bg-tertiary)); animation: eggSlotIn .18s cubic-bezier(.2,1.5,.3,1); }
+.egg-slot.is-next { border-style: dashed; border-color: color-mix(in srgb, var(--accent) 60%, transparent); }
+.hero-egg-slots.is-wrong .egg-slot { border-color: #ff5a5f; background: color-mix(in srgb, #ff5a5f 18%, var(--bg-tertiary)); animation: eggShakeSlot .45s; }
+.hero-egg-slots.is-right .egg-slot, .hero-egg-slots.is-solved .egg-slot { border-color: #06d6a0; background: color-mix(in srgb, #06d6a0 20%, var(--bg-tertiary)); }
+@keyframes eggSlotIn { from { transform: scale(0.7); } to { transform: scale(1); } }
+@keyframes eggShakeSlot { 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(3px); } }
+.hero-egg-clues { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; counter-reset: clue; }
+.hero-egg-clues li { display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 8px; font-size: 0.84rem; color: var(--text-primary); padding: 6px 8px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); }
+.egg-clue-n { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 0.7rem; font-weight: 800; color: #fff; background: var(--accent); }
+.egg-clue-text { font-style: italic; line-height: 1.35; }
+.egg-clue-key { font-weight: 900; font-size: 0.95rem; color: #06d6a0; background: color-mix(in srgb, #06d6a0 15%, transparent); border-radius: 8px; padding: 2px 9px; }
+.hero-egg-clues li.is-revealed { border-color: color-mix(in srgb, #06d6a0 45%, var(--border)); }
+.hero-egg-pad { display: grid; gap: 6px; margin-bottom: 10px; }
+.hero-egg-pad-arrows { display: flex; gap: 6px; flex-wrap: wrap; }
+.hero-egg-pad-letters { display: grid; grid-template-columns: repeat(13, 1fr); gap: 4px; }
+.egg-key { min-width: 34px; height: 34px; padding: 0 8px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-weight: 800; cursor: pointer; transition: transform .1s, background .15s, border-color .15s; }
+.egg-key:hover { border-color: var(--accent); }
+.egg-key:active { transform: scale(0.92); background: color-mix(in srgb, var(--accent) 25%, var(--bg-tertiary)); }
+.egg-key--letter { min-width: 0; height: 30px; padding: 0; font-size: 0.8rem; text-transform: uppercase; }
+.egg-key--ctl { margin-left: auto; color: var(--text-secondary); }
+.egg-key--ctl + .egg-key--ctl { margin-left: 0; }
+.hero-egg-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
+.hero-egg-reveal { border: 1px solid color-mix(in srgb, #fbbf24 45%, var(--border)); background: color-mix(in srgb, #fbbf24 10%, transparent); color: var(--text-primary); border-radius: 999px; padding: 5px 11px; font: inherit; font-size: 0.76rem; font-weight: 700; cursor: pointer; }
+.hero-egg-reveal:disabled { opacity: 0.6; cursor: default; }
+.hero-egg-reveal:not(:disabled):hover { border-color: #fbbf24; }
+.hero-egg-meta { font-size: 0.72rem; color: var(--text-muted); }
+@media (max-width: 480px) { .hero-egg-pad-letters { grid-template-columns: repeat(9, 1fr); } .egg-slot { width: 32px; height: 38px; } }
/* Celebration effects */
/* Celebration effects */
.egg-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 100000; overflow: hidden; }
.egg-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 100000; overflow: hidden; }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=160">
+ <link rel="stylesheet" href="/css/style.css?v=161">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -237,9 +237,26 @@
@@ -237,9 +237,26 @@
<i class="fa-solid fa-chevron-down hero-egg-chev"></i>
<i class="fa-solid fa-chevron-down hero-egg-chev"></i>
</button>
</button>
<div class="hero-egg-body">
<div class="hero-egg-body">
- <p class="hero-egg-intro">A fresh AI-made secret drops daily. Decode the hints into a key sequence, then type it anywhere on the site to crack it.</p>
- <ul class="hero-egg-hints" id="hero-egg-hints"></ul>
- <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-reset"></span></div>
+ <div class="hero-egg-inner">
+ <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
+ <div class="hero-egg-slots" id="hero-egg-slots" aria-label="Your key sequence"></div>
+ <ol class="hero-egg-clues" id="hero-egg-clues"></ol>
+ <div class="hero-egg-pad" id="hero-egg-pad">
+ <div class="hero-egg-pad-arrows">
+ <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>
+ <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>
+ <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>
+ <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>
+ <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>
+ <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>
+ </div>
+ <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>
+ </div>
+ <div class="hero-egg-foot">
+ <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>
+ <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>
+ </div>
+ </div>
</div>
</div>
</div>
</div>
<div class="hero-cta">
<div class="hero-cta">
@@ -5052,7 +5069,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5052,7 +5069,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
-<script src="/js/app.js?v=138" defer></script>
+<script src="/js/app.js?v=139" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1685,30 +1685,86 @@ function openHomeAbout() {
@@ -1685,30 +1685,86 @@ function openHomeAbout() {
// ── Daily AI easter egg ─────────────────────────────────────────────────────
// ── Daily AI easter egg ─────────────────────────────────────────────────────
let _egg = null, _eggBuf = [], _eggSolved = false, _eggSubmitTimer = null, _eggKeysWired = false;
let _egg = null, _eggBuf = [], _eggSolved = false, _eggSubmitTimer = null, _eggKeysWired = false;
const _EGG_ARROW = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
const _EGG_ARROW = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
+const _EGG_GLYPH = { up: '↑', down: '↓', left: '←', right: '→' };
+let _eggRevealed = []; // [{index, token}] revealed for this solver today
+let _eggFails = 0, _eggBusy = false;
async function loadHeroEgg() {
async function loadHeroEgg() {
const el = document.getElementById('hero-egg');
const el = document.getElementById('hero-egg');
if (!el) return;
if (!el) return;
let data; try { data = await api('/easter-egg/daily'); } catch { return; }
let data; try { data = await api('/easter-egg/daily'); } catch { return; }
const egg = data && data.egg;
const egg = data && data.egg;
if (!egg) { el.style.display = 'none'; return; }
if (!egg) { el.style.display = 'none'; return; }
- _egg = egg; _eggSolved = !!egg.solved;
+ _egg = egg; _eggSolved = !!egg.solved; _eggBuf = []; _eggRevealed = egg.revealed || []; _eggFails = egg.fails || 0;
el.style.display = '';
el.style.display = '';
const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };
const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };
set('hero-egg-title', egg.title || 'The Daily Secret');
set('hero-egg-title', egg.title || 'The Daily Secret');
- const hints = document.getElementById('hero-egg-hints');
- if (hints) hints.innerHTML = (egg.hints || []).length ? egg.hints.map(h => `<li>${esc(h)}</li>`).join('') : '<li>No hints today — go on instinct.</li>';
- set('hero-egg-count', `${egg.foundCount || 0} cracked it today`);
- const reset = document.getElementById('hero-egg-reset');
- if (reset) reset.textContent = egg.nextResetAt ? ` · resets ${_eggResetLabel(egg.nextResetAt)}` : '';
+ // Letter pad (built once)
+ const letters = document.getElementById('hero-egg-letters');
+ if (letters && !letters.childElementCount) letters.innerHTML = 'abcdefghijklmnopqrstuvwxyz'.split('').map(l => `<button type="button" class="egg-key egg-key--letter" data-tok="${l}">${l}</button>`).join('');
+ const pad = document.getElementById('hero-egg-pad');
+ if (pad && !pad.dataset.wired) {
+ pad.dataset.wired = '1';
+ pad.addEventListener('click', (e) => {
+ const b = e.target.closest('button'); if (!b) return;
+ if (b.dataset.act === 'back') { _eggBuf.pop(); _renderEggSlots(); return; }
+ if (b.dataset.act === 'clear') { _eggBuf = []; _renderEggSlots(); return; }
+ if (b.dataset.tok) _eggPush(b.dataset.tok);
+ });
+ }
+ _renderEggClues();
+ _renderEggSlots();
+ _renderEggMeta();
_renderEggStatus();
_renderEggStatus();
_wireEggKeys();
_wireEggKeys();
}
}
function _eggResetLabel(ts) { const h = Math.round((ts - Date.now()) / 3600000); return h <= 1 ? 'soon' : `in ${h}h`; }
function _eggResetLabel(ts) { const h = Math.round((ts - Date.now()) / 3600000); return h <= 1 ? 'soon' : `in ${h}h`; }
function _renderEggStatus() {
function _renderEggStatus() {
const s = document.getElementById('hero-egg-status'); if (!s) return;
const s = document.getElementById('hero-egg-status'); if (!s) return;
- s.textContent = _eggSolved ? '✓ Solved' : `${_egg ? _egg.codeLength : ''} keys`;
+ s.textContent = _eggSolved ? '✓ Cracked' : `${_egg ? _egg.codeLength : ''} keys`;
s.className = 'hero-egg-status' + (_eggSolved ? ' solved' : '');
s.className = 'hero-egg-status' + (_eggSolved ? ' solved' : '');
}
}
+function _renderEggMeta() {
+ if (!_egg) return;
+ const set = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = v; };
+ set('hero-egg-count', `${_egg.foundCount || 0} cracked it today`);
+ set('hero-egg-first', (_egg.firstSolvers || []).length ? ` · first: ${_egg.firstSolvers.slice(0, 3).join(', ')}` : '');
+ set('hero-egg-reset', _egg.nextResetAt ? ` · new secret ${_eggResetLabel(_egg.nextResetAt)}` : '');
+ const rb = document.getElementById('hero-egg-reveal');
+ if (rb) {
+ const left = Math.max(0, (_egg.revealAfterFails || 2) - _eggFails);
+ const done = _eggRevealed.length >= Math.max(0, _egg.codeLength - 1);
+ rb.style.display = _eggSolved ? 'none' : '';
+ rb.disabled = done || left > 0;
+ rb.innerHTML = done ? '<i class="fa-solid fa-lightbulb"></i> The last key is yours'
+ : left > 0 ? `<i class="fa-regular fa-lightbulb"></i> Stuck? ${left} more wrong tr${left === 1 ? 'y' : 'ies'} unlocks a reveal`
+ : '<i class="fa-solid fa-lightbulb"></i> Stuck? Reveal the next key';
+ }
+}
+function _renderEggClues() {
+ const ol = document.getElementById('hero-egg-clues'); if (!ol || !_egg) return;
+ const clues = _egg.clues || _egg.hints || [];
+ const rev = new Map(_eggRevealed.map(r => [r.index, r.token]));
+ ol.innerHTML = clues.map((c, i) => `<li class="${rev.has(i) ? 'is-revealed' : ''}"><span class="egg-clue-n">${i + 1}</span><span class="egg-clue-text">${esc(c)}</span>${rev.has(i) ? `<span class="egg-clue-key" title="Revealed">${esc(_EGG_GLYPH[rev.get(i)] || rev.get(i).toUpperCase())}</span>` : ''}</li>`).join('')
+ || '<li><span class="egg-clue-text">No clues today — go on instinct.</span></li>';
+}
+function _renderEggSlots(state) {
+ const host = document.getElementById('hero-egg-slots'); if (!host || !_egg) return;
+ const n = _egg.codeLength || 0;
+ let html = '';
+ for (let i = 0; i < n; i++) {
+ const t = _eggBuf[i];
+ html += `<span class="egg-slot ${t ? 'is-filled' : ''} ${i === _eggBuf.length ? 'is-next' : ''}">${t ? esc(_EGG_GLYPH[t] || t.toUpperCase()) : ''}</span>`;
+ }
+ host.innerHTML = html;
+ host.className = 'hero-egg-slots' + (state ? ` is-${state}` : '') + (_eggSolved ? ' is-solved' : '');
+}
+function _eggPush(tok) {
+ if (_eggSolved || !_egg || _eggBusy) return;
+ _eggBuf.push(tok);
+ if (_eggBuf.length > _egg.codeLength) _eggBuf = _eggBuf.slice(-_egg.codeLength);
+ _renderEggSlots();
+ if (_eggBuf.length === _egg.codeLength) { clearTimeout(_eggSubmitTimer); _eggSubmitTimer = setTimeout(_submitEgg, 200); }
+}
function toggleHeroEgg() { document.getElementById('hero-egg')?.classList.toggle('open'); }
function toggleHeroEgg() { document.getElementById('hero-egg')?.classList.toggle('open'); }
function _wireEggKeys() {
function _wireEggKeys() {
if (_eggKeysWired) return;
if (_eggKeysWired) return;
@@ -1716,23 +1772,39 @@ function _wireEggKeys() {
@@ -1716,23 +1772,39 @@ function _wireEggKeys() {
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (_eggSolved || !_egg) return;
if (_eggSolved || !_egg) return;
const t = e.target;
const t = e.target;
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
+ if (e.key === 'Backspace') { _eggBuf.pop(); _renderEggSlots(); return; }
let tok = _EGG_ARROW[e.key];
let tok = _EGG_ARROW[e.key];
if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();
if (!tok && /^[a-zA-Z]$/.test(e.key)) tok = e.key.toLowerCase();
if (!tok) return;
if (!tok) return;
- _eggBuf.push(tok);
- if (_eggBuf.length > _egg.codeLength) _eggBuf = _eggBuf.slice(-_egg.codeLength);
- if (_eggBuf.length === _egg.codeLength) { clearTimeout(_eggSubmitTimer); _eggSubmitTimer = setTimeout(_submitEgg, 220); }
+ if (_EGG_ARROW[e.key]) e.preventDefault(); // don't scroll the page on arrows
+ _eggPush(tok);
+ // Typing counts as engagement: pop the panel open so people see the keys land.
+ const el = document.getElementById('hero-egg'); if (el && !el.classList.contains('open') && _eggBuf.length === 1) el.classList.add('open');
});
});
}
}
async function _submitEgg() {
async function _submitEgg() {
- if (_eggSolved || !_egg || _eggBuf.length < _egg.codeLength) return;
- let res; try { res = await api('/easter-egg/solve', { method: 'POST', body: { sequence: _eggBuf.slice() } }); } catch { return; }
+ if (_eggSolved || !_egg || _eggBuf.length < _egg.codeLength || _eggBusy) return;
+ _eggBusy = true;
+ let res; try { res = await api('/easter-egg/solve', { method: 'POST', body: { sequence: _eggBuf.slice() } }); } catch { _eggBusy = false; return; }
+ _eggBusy = false;
if (res && res.solved) {
if (res && res.solved) {
- _eggSolved = true; _renderEggStatus();
- const c = document.getElementById('hero-egg-count'); if (c && res.foundCount != null) c.textContent = `${res.foundCount} cracked it today`;
+ _eggSolved = true; _renderEggStatus(); _renderEggSlots('right');
+ if (res.foundCount != null) _egg.foundCount = res.foundCount;
+ _renderEggMeta();
_celebrateEgg(res.egg || {});
_celebrateEgg(res.egg || {});
+ return;
}
}
+ _eggFails = (res && res.fails) || (_eggFails + 1);
+ _renderEggSlots('wrong');
+ _renderEggMeta();
+ setTimeout(() => { _eggBuf = []; _renderEggSlots(); }, 750);
+}
+async function _eggReveal() {
+ if (_eggSolved || !_egg) return;
+ let out; try { out = await api('/easter-egg/reveal', { method: 'POST', body: {} }); } catch (e) { try { toast((e && e.message) || 'Not yet', 'info'); } catch { /* */ } return; }
+ if (out && out.revealed) { _eggRevealed = out.revealed; _renderEggClues(); _renderEggMeta(); }
}
}
function _celebrateEgg(egg) {
function _celebrateEgg(egg) {
try { toast(egg.reward || "You cracked today's secret! 🎉", 'success'); } catch { /* */ }
try { toast(egg.reward || "You cracked today's secret! 🎉", 'success'); } catch { /* */ }
@@ -26,9 +26,12 @@ function _due() { const p = _load(); return !p.date || p.date !== _today() || !A
@@ -26,9 +26,12 @@ function _due() { const p = _load(); return !p.date || p.date !== _today() || !A
function _seededCode() {
function _seededCode() {
let seed = 0; for (const ch of _today()) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;
let seed = 0; for (const ch of _today()) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
- const len = 6 + Math.floor(rnd() * 3);
+ const len = 5 + Math.floor(rnd() * 3);
const code = [];
const code = [];
- for (let i = 0; i < len; i++) code.push(TOKENS[Math.floor(rnd() * TOKENS.length)]);
+ // Konami feel: roughly half arrows, half letters, mixed.
+ for (let i = 0; i < len; i++) code.push(rnd() < 0.55 ? DIRS[Math.floor(rnd() * DIRS.length)] : LETTERS[Math.floor(rnd() * LETTERS.length)]);
+ if (!code.some(t => DIRS.includes(t))) code[0] = DIRS[Math.floor(rnd() * DIRS.length)];
+ if (!code.some(t => !DIRS.includes(t))) code[len - 1] = LETTERS[Math.floor(rnd() * LETTERS.length)];
return code;
return code;
}
}
function _sanitizeCode(arr) {
function _sanitizeCode(arr) {
@@ -36,28 +39,57 @@ function _sanitizeCode(arr) {
@@ -36,28 +39,57 @@ function _sanitizeCode(arr) {
const code = arr.map(t => String(t || '').toLowerCase().trim())
const code = arr.map(t => String(t || '').toLowerCase().trim())
.map(t => ({ arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right' }[t] || t))
.map(t => ({ arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right' }[t] || t))
.filter(t => TOKENS.includes(t));
.filter(t => TOKENS.includes(t));
- return (code.length >= 5 && code.length <= 12) ? code : null;
+ return (code.length >= 4 && code.length <= 9) ? code : null;
+}
+
+// ── Clue bank: a fair, readable clue for every token (used for the no-AI fallback and to
+// patch any AI clue that is missing or does not line up with the code) ─────────────────
+const DIR_CLUES = {
+ up: ['toward the sky', 'the way a balloon goes', 'north on the compass', 'where the ceiling lives', 'the arrow that climbs'],
+ down: ['toward the floor', 'the way rain falls', 'south on the compass', 'where roots grow', 'the arrow that sinks'],
+ left: ['the side your heart is on', 'west on the compass', 'the arrow that points at the past', 'port side, sailor', 'where the sun sets'],
+ right: ['the side most people write with', 'east on the compass', 'the arrow that points at the future', 'starboard, sailor', 'where the sun rises'],
+};
+const LETTER_WORDS = { a: 'arena', b: 'broadcast', c: 'clip', d: 'donate', e: 'emote', f: 'follow', g: 'goose', h: 'hype', i: 'irl', j: 'jump', k: 'keyboard', l: 'latency', m: 'mic', n: 'network', o: 'overlay', p: 'paste', q: 'queue', r: 'robot', s: 'stream', t: 'tts', u: 'upload', v: 'vibe', w: 'webrtc', x: 'xp', y: 'yap', z: 'zoom' };
+const ORD = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth'];
+function _clueFor(tok, i, rnd) {
+ if (DIR_CLUES[tok]) { const list = DIR_CLUES[tok]; return list[(i + Math.floor(rnd() * list.length)) % list.length]; }
+ const w = LETTER_WORDS[tok] || tok;
+ const forms = [`the letter that starts "${w}"`, `"${w}" begins with it`, `the ${ORD[0]} letter of "${w}"`];
+ return forms[Math.floor(rnd() * forms.length)];
+}
+function _rngFor(seedStr) { let seed = 7; for (const ch of seedStr) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0; return () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; }
+/** One clue per token, in order — AI clues kept when they line up, bank clues fill the gaps. */
+function _alignClues(code, aiClues) {
+ const rnd = _rngFor(_today() + code.join(','));
+ const out = [];
+ for (let i = 0; i < code.length; i++) {
+ const c = Array.isArray(aiClues) && aiClues[i] ? String(aiClues[i]).replace(/\s+/g, ' ').trim().slice(0, 110) : '';
+ out.push(c || _clueFor(code[i], i, rnd));
+ }
+ return out;
}
}
async function _generate() {
async function _generate() {
let vibe = '';
let vibe = '';
try { const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }
try { const g = chatAi && chatAi.getGlobalInsight && chatAi.getGlobalInsight(); if (g && g.overview) vibe = String(g.overview).slice(0, 400); } catch { /* */ }
-
if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
if (ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()) {
- const prompt = `Invent today's secret "Konami-style" easter egg for OpenVibe.Live, a hobbyist live-streaming site.${vibe ? `\nToday's community vibe (for flavour): ${vibe}` : ''}
-
+ const prompt = `Invent today's secret "Konami-style" code for OpenVibe.Live, a hobbyist live-streaming site.${vibe ? `\nToday's community vibe (for flavour only): ${vibe}` : ''}
Return STRICT JSON only, nothing else:
Return STRICT JSON only, nothing else:
{
{
"title": "<punchy name for today's secret, 2-5 words>",
"title": "<punchy name for today's secret, 2-5 words>",
- "code": [<6 to 9 tokens IN ORDER, each exactly one of: "up","down","left","right", or a single lowercase letter a-z>],
- "hints": ["<hint 1>", "<hint 2>", "<hint 3>"],
+ "code": [<5 to 7 tokens IN ORDER, each exactly one of: "up","down","left","right", or a single lowercase letter a-z; use at least 2 arrows and at least 1 letter>],
+ "clues": [<EXACTLY one clue per token, same order, each ≤ 90 chars>],
"effect": "<one of: confetti, fireworks, matrix, rainbow, shake>",
"effect": "<one of: confetti, fireworks, matrix, rainbow, shake>",
"reward": "<a short, fun congratulatory line shown when a user cracks it>"
"reward": "<a short, fun congratulatory line shown when a user cracks it>"
}
}
-
-The hints, taken together and in order, must cryptically ENCODE the exact code sequence (use riddles/wordplay for directions and letters — e.g. "twice toward the heavens" = up,up) WITHOUT ever listing the tokens literally. Make it clever but genuinely figure-out-able. Keep it playful and a little unhinged.`;
+CLUE RULES (this is a game people must actually be able to win):
+- Clue i describes token i and ONLY token i. Never list the token literally, but make it fair: a normal person should get it in one or two guesses.
+- Arrow clues use direction imagery ("where the sun rises" = right, "toward the sky" = up, "the way rain falls" = down, "the side your heart is on" = left).
+- Letter clues name a common word the letter starts ("the letter that starts 'goose'" = g), or a famous initial.
+- Keep the flavour playful and on-theme, but clarity beats cleverness.`;
try {
try {
- const text = await ai.summarizeText(prompt, 500, 'easter_egg');
+ const text = await ai.summarizeText(prompt, 600, 'easter_egg');
const m = text && text.match(/\{[\s\S]*\}/);
const m = text && text.match(/\{[\s\S]*\}/);
if (m) {
if (m) {
const j = JSON.parse(m[0]);
const j = JSON.parse(m[0]);
@@ -66,7 +98,7 @@ The hints, taken together and in order, must cryptically ENCODE the exact code s
@@ -66,7 +98,7 @@ The hints, taken together and in order, must cryptically ENCODE the exact code s
return {
return {
title: String(j.title || 'The Daily Secret').replace(/\s+/g, ' ').trim().slice(0, 60),
title: String(j.title || 'The Daily Secret').replace(/\s+/g, ' ').trim().slice(0, 60),
code,
code,
- hints: (Array.isArray(j.hints) ? j.hints : []).map(h => String(h || '').trim().slice(0, 160)).filter(Boolean).slice(0, 4),
+ clues: _alignClues(code, j.clues || j.hints),
effect: EFFECTS.includes(j.effect) ? j.effect : 'confetti',
effect: EFFECTS.includes(j.effect) ? j.effect : 'confetti',
reward: String(j.reward || "You cracked today's secret! 🎉").trim().slice(0, 200),
reward: String(j.reward || "You cracked today's secret! 🎉").trim().slice(0, 200),
ai: true,
ai: true,
@@ -75,21 +107,15 @@ The hints, taken together and in order, must cryptically ENCODE the exact code s
@@ -75,21 +107,15 @@ The hints, taken together and in order, must cryptically ENCODE the exact code s
}
}
} catch { /* fall through */ }
} catch { /* fall through */ }
}
}
- // Fallback
+ // Fallback: deterministic daily code with bank clues — always solvable.
const code = _seededCode();
const code = _seededCode();
- const dirWord = { up: 'skyward', down: 'to the depths', left: 'to the west', right: 'to the east' };
return {
return {
title: 'The Daily Secret',
title: 'The Daily Secret',
code,
code,
- hints: [
- 'The old gamers knew: four winds and a few good letters open the way.',
- 'Watch the arrows and whisper the letters, in the order the day decided.',
- 'No shame in guessing — the brave stumble onto it.',
- ],
+ clues: _alignClues(code, null),
effect: EFFECTS[code.length % EFFECTS.length],
effect: EFFECTS[code.length % EFFECTS.length],
reward: "You cracked today's secret! 🎉",
reward: "You cracked today's secret! 🎉",
ai: false,
ai: false,
- void: dirWord,
};
};
}
}
@@ -111,15 +137,44 @@ function getPublic() {
@@ -111,15 +137,44 @@ function getPublic() {
if (!p.code || p.date !== _today()) return null;
if (!p.code || p.date !== _today()) return null;
const now = new Date();
const now = new Date();
const nextReset = new Date(now); nextReset.setHours(24, 0, 0, 0);
const nextReset = new Date(now); nextReset.setHours(24, 0, 0, 0);
+ const clues = Array.isArray(p.clues) && p.clues.length === p.code.length ? p.clues : _alignClues(p.code, p.clues || p.hints);
return {
return {
date: p.date,
date: p.date,
title: p.title || 'The Daily Secret',
title: p.title || 'The Daily Secret',
- hints: p.hints || [],
+ clues,
+ hints: clues, // back-compat for older clients
codeLength: p.code.length,
codeLength: p.code.length,
effect: p.effect || 'confetti',
effect: p.effect || 'confetti',
nextResetAt: nextReset.getTime(),
nextResetAt: nextReset.getTime(),
+ ai: !!p.ai,
};
};
}
}
+
+// ── "Stuck?" reveals: after a couple of wrong attempts a solver may reveal keys one at a time
+// (never the last one) — enough to keep it fun, not enough to make typing pointless. Per-day,
+// per-solver, in memory (a restart just makes people try twice more).
+const _fails = new Map(); // solverKey → { date, n, revealed:Set }
+function _rec(solverKey) {
+ const d = _today(); let r = _fails.get(solverKey);
+ if (!r || r.date !== d) { r = { date: d, n: 0, revealed: new Set() }; _fails.set(solverKey, r); }
+ if (_fails.size > 5000) { const k = _fails.keys().next().value; _fails.delete(k); }
+ return r;
+}
+function noteFail(solverKey) { const r = _rec(solverKey); r.n++; return r.n; }
+const REVEAL_AFTER_FAILS = 2;
+function reveal(solverKey, index) {
+ const p = _load();
+ if (!p.code || p.date !== _today()) return { error: 'No secret today' };
+ const r = _rec(solverKey);
+ const i = Number.isInteger(index) ? index : [...Array(p.code.length).keys()].find(k => !r.revealed.has(k));
+ if (i == null || i < 0 || i >= p.code.length) return { error: 'Nothing left to reveal' };
+ if (i === p.code.length - 1 && !r.revealed.has(i)) return { error: 'The last key is yours to find', last: true };
+ if (r.n < REVEAL_AFTER_FAILS && !r.revealed.has(i)) return { error: `Try ${REVEAL_AFTER_FAILS - r.n} more time${REVEAL_AFTER_FAILS - r.n === 1 ? '' : 's'} first`, needFails: REVEAL_AFTER_FAILS - r.n };
+ r.revealed.add(i);
+ return { index: i, token: p.code[i], revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) };
+}
+function revealedFor(solverKey) { const p = _load(); const r = _fails.get(solverKey); if (!p.code || !r || r.date !== _today()) return { fails: r ? r.n : 0, revealed: [] }; return { fails: r.n, revealed: [...r.revealed].sort((a, b) => a - b).map(k => ({ index: k, token: p.code[k] })) }; }
+
// Validate an attempt against today's secret code (server-side only).
// Validate an attempt against today's secret code (server-side only).
function checkSolution(sequence) {
function checkSolution(sequence) {
const p = _load();
const p = _load();
@@ -138,9 +193,9 @@ function start() {
@@ -138,9 +193,9 @@ function start() {
console.log('[EasterEgg] Daily easter-egg job started');
console.log('[EasterEgg] Daily easter-egg job started');
}
}
-module.exports = { start, tick, getPublic, checkSolution };
+module.exports = { start, tick, getPublic, checkSolution, noteFail, reveal, revealedFor, REVEAL_AFTER_FAILS };
// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`
// CLI: force a fresh egg now — `node server/ai/easter-egg-job.js`
if (require.main === module) {
if (require.main === module) {
- tick({ force: true }).then(() => { const p = _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, hints: p.hints, effect: p.effect })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });
+ tick({ force: true }).then(() => { const p = _load(); console.log('Egg:', JSON.stringify({ title: p.title, code: p.code, clues: p.clues, effect: p.effect, ai: p.ai })); process.exit(0); }).catch(e => { console.error(e); process.exit(1); });
}
}
@@ -17,11 +17,18 @@ router.get('/daily', optionalAuth, (req, res) => {
@@ -17,11 +17,18 @@ router.get('/daily', optionalAuth, (req, res) => {
try {
try {
const pub = eggJob.getPublic();
const pub = eggJob.getPublic();
if (!pub) return res.json({ egg: null });
if (!pub) return res.json({ egg: null });
+ const mine = eggJob.revealedFor(_solverKey(req));
+ let firstSolvers = [];
+ try { firstSolvers = (db.all('SELECT u.username, u.display_name FROM easter_egg_solves s JOIN users u ON u.id = s.user_id WHERE s.egg_date = ? ORDER BY s.rowid ASC LIMIT 3', [pub.date]) || []).map(r => r.display_name || r.username); } catch { firstSolvers = []; }
res.json({
res.json({
egg: {
egg: {
...pub,
...pub,
foundCount: db.countEasterEggSolves(pub.date),
foundCount: db.countEasterEggSolves(pub.date),
solved: db.hasSolvedEasterEgg(pub.date, _solverKey(req)),
solved: db.hasSolvedEasterEgg(pub.date, _solverKey(req)),
+ firstSolvers,
+ fails: mine.fails,
+ revealed: mine.revealed,
+ revealAfterFails: eggJob.REVEAL_AFTER_FAILS,
},
},
});
});
} catch (err) {
} catch (err) {
@@ -44,7 +51,7 @@ router.post('/solve', optionalAuth, (req, res) => {
@@ -44,7 +51,7 @@ router.post('/solve', optionalAuth, (req, res) => {
if (!seq) return res.status(400).json({ error: 'sequence required' });
if (!seq) return res.status(400).json({ error: 'sequence required' });
const result = eggJob.checkSolution(seq);
const result = eggJob.checkSolution(seq);
- if (!result || !result.ok) return res.json({ solved: false });
+ if (!result || !result.ok) { const fails = eggJob.noteFail(_solverKey(req)); return res.json({ solved: false, fails, canReveal: fails >= eggJob.REVEAL_AFTER_FAILS }); }
const firstTime = db.recordEasterEggSolve(result.egg.date, _solverKey(req), (req.user && req.user.id) || null);
const firstTime = db.recordEasterEggSolve(result.egg.date, _solverKey(req), (req.user && req.user.id) || null);
res.json({
res.json({
@@ -58,4 +65,14 @@ router.post('/solve', optionalAuth, (req, res) => {
@@ -58,4 +65,14 @@ router.post('/solve', optionalAuth, (req, res) => {
}
}
});
});
+// "Stuck?" — reveal one key (after a couple of wrong tries; never the last key).
+router.post('/reveal', optionalAuth, (req, res) => {
+ try {
+ const idx = req.body && Number.isInteger(req.body.index) ? req.body.index : null;
+ const out = eggJob.reveal(_solverKey(req), idx);
+ if (out.error) return res.status(out.needFails ? 425 : 403).json(out);
+ res.json(out);
+ } catch (err) { res.status(500).json({ error: 'Failed to reveal' }); }
+});
+
module.exports = router;
module.exports = router;