← back to Abrams

public/js/gamify.js

248 lines

// gamify.js — XP / levels / badges / streaks / confetti for the Abrams Panel.
// Drop-in: include this script and call window.Gamify.event('action', xp) anywhere.
// Persists per-player in localStorage + posts events to /api/gamify/event for the leaderboard.

(function () {
  const STORE_KEY = 'abrams.gamify.v1';
  const SOUND_KEY = 'abrams.gamify.sound';
  const PLAYER_KEY = 'abrams.gamify.player';

  // ── load or init player ────────────────────────────────────────────
  let player = localStorage.getItem(PLAYER_KEY);
  if (!player) {
    player = 'agent-' + Math.random().toString(36).slice(2, 8);
    localStorage.setItem(PLAYER_KEY, player);
  }

  let state = {
    player,
    xp: 0,
    level: 1,
    streak_days: 0,
    last_seen_day: null,
    badges: [],
    events_seen: 0,
  };
  try { Object.assign(state, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); state.player = player; } catch {}

  const soundOn = () => localStorage.getItem(SOUND_KEY) !== 'off';
  const setSound = (on) => localStorage.setItem(SOUND_KEY, on ? 'on' : 'off');

  // ── levels (Fibonacci-style growth) ────────────────────────────────
  const LEVELS = [0, 50, 150, 350, 750, 1500, 3000, 6000, 12000, 24000, 50000];
  const levelFromXp = (xp) => { for (let i = LEVELS.length - 1; i >= 0; i--) if (xp >= LEVELS[i]) return i + 1; return 1; };
  const xpNeededForNext = (lvl) => LEVELS[lvl] || (LEVELS[LEVELS.length - 1] * 2);

  // ── badge catalog ──────────────────────────────────────────────────
  const BADGES = {
    'first-step':       { name: 'First Step',       icon: '◐',  desc: 'Visited the panel',                 trigger: s => s.events_seen >= 1 },
    'curious-mind':     { name: 'Curious Mind',     icon: '✦',  desc: 'Viewed 5+ projects',                 trigger: s => (s._actions?.project_view || 0) >= 5 },
    'thesis-reader':    { name: 'Thesis Reader',    icon: '※',  desc: 'Scrolled through the brand thesis',  trigger: s => (s._actions?.thesis_view || 0) >= 1 },
    'mag-20-auditor':   { name: 'Mag-20 Auditor',   icon: '⚐',  desc: 'Opened the Mag-20 audit page',       trigger: s => (s._actions?.mag20_view || 0) >= 1 },
    'swot-scholar':     { name: 'SWOT Scholar',     icon: '⚖',  desc: 'Opened the SWOT ledger',             trigger: s => (s._actions?.swot_view || 0) >= 1 },
    'first-blood':      { name: 'First Blood',      icon: '★',  desc: 'Submitted the first lead',           trigger: s => (s._actions?.lead_submit || 0) >= 1 },
    'wall-poster':      { name: 'Wall Poster',      icon: '✎',  desc: 'Posted to the Wall',                 trigger: s => (s._actions?.wall_post || 0) >= 1 },
    'chatter':          { name: 'Chatter',          icon: '◗',  desc: 'Talked to Abrams 3+ times',          trigger: s => (s._actions?.chat_msg || 0) >= 3 },
    'tm-investigator':  { name: 'TM Investigator',  icon: '®',  desc: 'Studied the Trademark hub',          trigger: s => (s._actions?.trademark_view || 0) >= 1 },
    'tick-witness':     { name: 'Tick Witness',     icon: '⟳',  desc: 'Watched 3+ live build events',       trigger: s => (s._actions?.tick_witnessed || 0) >= 3 },
    'streak-3':         { name: '3-Day Streak',     icon: '✶✶✶', desc: 'Visited 3 days in a row',           trigger: s => s.streak_days >= 3 },
    'streak-7':         { name: 'Week Streak',      icon: '✸',  desc: 'Visited 7 days in a row',            trigger: s => s.streak_days >= 7 },
    'high-roller':      { name: 'High Roller',      icon: '✤',  desc: 'Reached 1,500 XP',                   trigger: s => s.xp >= 1500 },
    'panel-master':     { name: 'Panel Master',     icon: '✜',  desc: 'Reached 6,000 XP',                   trigger: s => s.xp >= 6000 },
  };

  // ── tone/beep (uses WebAudio so no asset loading) ──────────────────
  let audioCtx = null;
  function beep(freq = 660, dur = 0.08, type = 'sine', vol = 0.06) {
    if (!soundOn()) return;
    try {
      if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      const o = audioCtx.createOscillator(), g = audioCtx.createGain();
      o.type = type; o.frequency.value = freq;
      g.gain.value = vol;
      o.connect(g); g.connect(audioCtx.destination);
      o.start();
      g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
      o.stop(audioCtx.currentTime + dur);
    } catch {}
  }
  function chord() { beep(523, 0.1); setTimeout(() => beep(659, 0.1), 90); setTimeout(() => beep(784, 0.18), 180); }

  // ── confetti (canvas-free, span-based, lightweight) ────────────────
  function confetti(n = 80) {
    const colors = ['#c9a96e', '#fff', '#4ade80', '#f59e0b', '#94a3b8'];
    for (let i = 0; i < n; i++) {
      const el = document.createElement('span');
      el.style.cssText = `position:fixed;top:-12px;left:${Math.random()*100}vw;width:8px;height:14px;background:${colors[i%colors.length]};opacity:.9;z-index:9999;border-radius:2px;pointer-events:none;transform:rotate(${Math.random()*360}deg);transition:transform 2.5s linear, top 2.5s linear, opacity 2.5s ease-out;`;
      document.body.appendChild(el);
      requestAnimationFrame(() => {
        el.style.top = (60 + Math.random()*40) + 'vh';
        el.style.transform = `rotate(${360 + Math.random()*720}deg) translateX(${(Math.random()-0.5)*200}px)`;
        el.style.opacity = '0';
      });
      setTimeout(() => el.remove(), 2700);
    }
  }

  // ── HUD widget ────────────────────────────────────────────────────
  let hud;
  function buildHud() {
    if (hud) return;
    hud = document.createElement('div');
    hud.id = 'abrams-gamify-hud';
    hud.style.cssText = 'position:fixed;bottom:18px;right:18px;z-index:90;background:rgba(20,20,20,.92);backdrop-filter:blur(6px);border:1px solid #2a2620;padding:12px 16px;font-family:JetBrains Mono,monospace;font-size:11px;color:#f1ece4;letter-spacing:.1em;border-radius:14px;min-width:220px;box-shadow:0 8px 32px rgba(0,0,0,.5);cursor:pointer;user-select:none;';
    hud.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
        <span style="color:#c9a96e;font-weight:600;letter-spacing:.2em;text-transform:uppercase;">LVL <span id="ag-level">1</span></span>
        <span id="ag-sound" style="cursor:pointer;font-size:14px;opacity:.6;" title="toggle sound">♪</span>
      </div>
      <div style="display:flex;justify-content:space-between;color:#b8b1a4;margin-bottom:6px;">
        <span><span id="ag-xp">0</span> XP</span>
        <span><span id="ag-streak">0</span>🔥</span>
      </div>
      <div style="height:4px;background:#1d1d1d;border-radius:2px;overflow:hidden;margin-bottom:8px;">
        <div id="ag-bar" style="height:100%;background:linear-gradient(90deg,#c9a96e,#fff);width:0%;transition:width .5s;"></div>
      </div>
      <div id="ag-badges" style="display:flex;gap:4px;flex-wrap:wrap;font-size:14px;line-height:1;"></div>
      <div id="ag-popup" style="position:absolute;bottom:100%;right:0;margin-bottom:8px;background:#c9a96e;color:#111;padding:8px 14px;border-radius:8px;font-weight:600;letter-spacing:.15em;opacity:0;transform:translateY(8px);transition:all .3s;pointer-events:none;white-space:nowrap;text-transform:uppercase;font-size:10px;"></div>
    `;
    document.body.appendChild(hud);

    document.getElementById('ag-sound').addEventListener('click', e => {
      e.stopPropagation();
      const on = !soundOn(); setSound(on);
      document.getElementById('ag-sound').style.opacity = on ? '1' : '.3';
    });
    document.getElementById('ag-sound').style.opacity = soundOn() ? '1' : '.3';

    hud.addEventListener('click', () => {
      // expand to show full badges + leaderboard preview
      window.location.href = '/#leaderboard';
    });
  }

  function renderHud() {
    if (!hud) return;
    document.getElementById('ag-level').textContent = state.level;
    document.getElementById('ag-xp').textContent = state.xp;
    document.getElementById('ag-streak').textContent = state.streak_days;
    const cur = state.xp - (LEVELS[state.level - 1] || 0);
    const need = xpNeededForNext(state.level) - (LEVELS[state.level - 1] || 0);
    document.getElementById('ag-bar').style.width = Math.min(100, (cur / Math.max(1, need)) * 100) + '%';
    document.getElementById('ag-badges').innerHTML = state.badges.slice(-8).map(b => {
      const info = BADGES[b] || { icon: '◌', name: b };
      return `<span title="${info.name}: ${info.desc||''}" style="cursor:help;">${info.icon}</span>`;
    }).join('');
  }

  function popup(text) {
    const el = document.getElementById('ag-popup'); if (!el) return;
    el.textContent = text;
    el.style.opacity = '1'; el.style.transform = 'translateY(0)';
    setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(8px)'; }, 1800);
  }

  // ── streak tracker ────────────────────────────────────────────────
  function checkStreak() {
    const today = new Date().toISOString().slice(0, 10);
    if (state.last_seen_day === today) return;
    if (state.last_seen_day) {
      const d1 = new Date(state.last_seen_day).getTime(), d2 = new Date(today).getTime();
      const diff = Math.round((d2 - d1) / 86400000);
      if (diff === 1) state.streak_days += 1;
      else if (diff > 1) state.streak_days = 1;
    } else state.streak_days = 1;
    state.last_seen_day = today;
  }

  // ── core event API ────────────────────────────────────────────────
  function event(action, xp = 10) {
    state._actions = state._actions || {};
    state._actions[action] = (state._actions[action] || 0) + 1;
    state.xp += xp;
    state.events_seen += 1;
    const oldLevel = state.level;
    state.level = levelFromXp(state.xp);
    const newBadges = [];
    for (const [id, b] of Object.entries(BADGES)) {
      if (!state.badges.includes(id) && b.trigger(state)) {
        state.badges.push(id);
        newBadges.push(b);
      }
    }
    persist();
    renderHud();
    // animate
    if (xp > 0) { beep(660, 0.05); popup('+' + xp + ' XP'); }
    if (state.level > oldLevel) { chord(); confetti(); setTimeout(() => popup('LEVEL UP! LVL ' + state.level), 200); }
    for (const b of newBadges) { setTimeout(() => { beep(880, 0.12); popup('🏅 ' + b.name); }, 1200); }
    // post to server (fire-and-forget)
    try {
      fetch('/api/gamify/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ player: state.player, action, xp, level: state.level, badge: newBadges[0]?.name }) }).catch(()=>{});
    } catch {}
  }

  function persist() { try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch {} }

  // ── auto-event detectors based on current page ────────────────────
  function autoDetect() {
    const path = location.pathname;
    if (path === '/' || path === '/index.html') event('panel_view', 5);
    if (path.startsWith('/trademark')) event('trademark_view', 15);
    if (path.startsWith('/ideas')) event('swot_view', 15);
    if (path.startsWith('/wall')) event('wall_view', 10);
    if (path.startsWith('/chat')) event('chat_view', 10);
    if (path.startsWith('/mag-20')) event('mag20_view', 25);
    if (path.startsWith('/press')) event('press_view', 15);

    // thesis section view (IntersectionObserver)
    setTimeout(() => {
      const thesis = document.getElementById('thesis');
      if (thesis && 'IntersectionObserver' in window) {
        const io = new IntersectionObserver((entries) => {
          entries.forEach(e => { if (e.isIntersecting) { event('thesis_view', 10); io.disconnect(); } });
        }, { threshold: 0.3 });
        io.observe(thesis);
      }
    }, 800);

    // detect SSE build events being received → tick_witnessed (debounced)
    let lastTick = 0;
    document.addEventListener('abrams:build-event', () => {
      const now = Date.now();
      if (now - lastTick > 8000) { event('tick_witnessed', 2); lastTick = now; }
    });

    // detect lead-form submission
    const leadForm = document.querySelector('form.lead-form');
    if (leadForm) leadForm.addEventListener('submit', () => setTimeout(() => event('lead_submit', 100), 400));

    // detect wall-post button click
    const wallBtn = document.querySelector('.composer button');
    if (wallBtn && /POST/i.test(wallBtn.textContent || '')) wallBtn.addEventListener('click', () => event('wall_post', 30));

    // detect chat send
    const chatBtn = document.querySelector('button[onclick*="send()"]');
    if (chatBtn) chatBtn.addEventListener('click', () => event('chat_msg', 15));

    // detect project tile click on home
    document.querySelectorAll('.tile').forEach(t => t.addEventListener('click', () => event('project_view', 8), { once: true }));
  }

  // ── boot ──────────────────────────────────────────────────────────
  function boot() {
    buildHud();
    checkStreak();
    state.level = levelFromXp(state.xp);
    persist();
    renderHud();
    autoDetect();
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  else boot();

  // public surface
  window.Gamify = { event, state: () => ({ ...state }), confetti, beep };
})();