florr menu

florr mod-menu: Full auto AFK-check solver + petal/mob database browser. Support/requests: @kw0d932 on Discord.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

Advertisement:

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

Advertisement:

// ==UserScript==
// @name         florr menu
// @namespace    florr-menu
// @match        https://florr.io/*
// @run-at       document-start
// @grant        none
// @version      0.5
// @author       Discord: @kw0d932
// @license      MIT
// @description  florr mod-menu: Full auto AFK-check solver + petal/mob database browser. Support/requests: @kw0d932 on Discord.
// ==/UserScript==

(function () {
  'use strict';
  if (window.__florrMenu) return; window.__florrMenu = true;

  // ============================================================================================
  // AFK auto-solver - part 1: runs at document-start, BEFORE florr caches getContext/rAF.
  // Keeps florr's render loop running even when the tab is minimized (a Web Worker timer is not
  // throttled like a hidden tab's rAF), and wraps the 2D context to capture draw commands so we can
  // read the AFK dot's exact position from florr's own geometry. We only need commands, not pixels.
  // ============================================================================================
  window.__afk2 = { armed: false, active: false, frame: [], captured: null, frames: 0 };
  try {
    Object.defineProperty(document, 'hidden', { get: () => false, configurable: true });
    Object.defineProperty(document, 'visibilityState', { get: () => 'visible', configurable: true });
    window.addEventListener('visibilitychange', e => e.stopImmediatePropagation(), true);
    const _cbs = []; const _w = new Worker(URL.createObjectURL(new Blob(['setInterval(function(){postMessage(0)},15)'], { type: 'application/javascript' })));
    _w.onmessage = () => { const l = _cbs.splice(0), ts = performance.now(); for (const f of l) { try { f(ts); } catch (e) {} } };
    window.requestAnimationFrame = cb => { _cbs.push(cb); return _cbs.length; };
    window.cancelAnimationFrame = () => {};
  } catch (e) { console.log('[florr menu] AFK keepalive failed:', e.message); }
  const _afkTT = c => { try { const m = c.getTransform(); return [+m.a.toFixed(4), +m.b.toFixed(4), +m.c.toFixed(4), +m.d.toFixed(4), +m.e.toFixed(2), +m.f.toFixed(2)]; } catch (e) { return 0; } };
  (function () {
    const oGet = HTMLCanvasElement.prototype.getContext;
    HTMLCanvasElement.prototype.getContext = function (type) {
      const ctx = oGet.apply(this, arguments);
      if ((type === '2d' || type === '2D') && ctx && !ctx.__afkW) {
        ctx.__afkW = true;
        // geometry methods: record op + args + transform. Includes rect/ellipse/arcTo so we capture the
        // flappy check's bars/pipes (likely rectangles) and any rounded shapes, not just circles + paths.
        ['arc', 'arcTo', 'ellipse', 'rect', 'roundRect', 'beginPath', 'moveTo', 'lineTo', 'bezierCurveTo', 'quadraticCurveTo', 'closePath'].forEach(m => { if (typeof ctx[m] !== 'function') return; const o = ctx[m]; ctx[m] = function () { if (window.__afk2.active) { const a = [].slice.call(arguments); a.unshift(m); a.push(_afkTT(this)); window.__afk2.frame.push(a); } return o.apply(this, arguments); }; });
        ['fill', 'stroke'].forEach(m => { if (typeof ctx[m] !== 'function') return; const o = ctx[m]; ctx[m] = function () { if (window.__afk2.active) window.__afk2.frame.push([m, String(this.fillStyle), String(this.strokeStyle), _afkTT(this)]); return o.apply(this, arguments); }; });
        // fillRect/strokeRect paint a rectangle directly (no path) - capture geom + colour for the bars.
        ['fillRect', 'strokeRect'].forEach(m => { if (typeof ctx[m] !== 'function') return; const o = ctx[m]; ctx[m] = function (x, y, w, h) { if (window.__afk2.active) window.__afk2.frame.push([m, x, y, w, h, String(this.fillStyle), String(this.strokeStyle), _afkTT(this)]); return o.apply(this, arguments); }; });
        const odi = ctx.drawImage;
        if (typeof odi === 'function') ctx.drawImage = function (img) { if (window.__afk2.active) { const ar = [].slice.call(arguments); let dx, dy, dw, dh; if (ar.length >= 9) { dx = ar[5]; dy = ar[6]; dw = ar[7]; dh = ar[8]; } else if (ar.length >= 5) { dx = ar[1]; dy = ar[2]; dw = ar[3]; dh = ar[4]; } else { dx = ar[1]; dy = ar[2]; dw = (img && img.width) || 0; dh = (img && img.height) || 0; } window.__afk2.frame.push(['drawImage', (img && img.width) || 0, (img && img.height) || 0, dx, dy, dw, dh, _afkTT(this)]); } return odi.apply(this, arguments); };
        const oft = ctx.fillText;
        if (typeof oft === 'function') ctx.fillText = function (t, x, y) { try { if (/AFK Check|Drag the circle|Flap until/i.test(String(t))) window.__afk2.armed = true; if (window.__afk2.active) window.__afk2.frame.push(['fillText', String(t), x, y, String(this.fillStyle), _afkTT(this)]); } catch (e) {} return oft.apply(this, arguments); };
      }
      return ctx;
    };
    (function loop() { const a = window.__afk2; a.frames++; if (a.active) { a.captured = { cmds: a.frame }; a.active = false; } if (a.armed && !a.active) { a.armed = false; a.active = true; a.frame = []; } window.requestAnimationFrame(loop); })();
  })();

  // florr's UI font is the bundled webfont "Game".
  const C = {
    panel: '#db9d5a', panelEdge: '#bd8444', panelDark: '#c98f4e',
    cell: '#b17f49', cellEdge: '#9c6f40', green: '#7eef6d', greenEdge: '#5fc94f',
    gray: '#9a9a9a', grayEdge: '#7c7c7c', red: '#cf5b5b', redEdge: '#b04a4a', ink: '#ffffff'
  };

  const KEY = 'florrMenuSettings';
  let S = {}; try { S = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch (e) { S = {}; }
  const save = () => { try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) {} };
  const get = (k, d) => (k in S ? S[k] : d);
  const set = (k, v) => { S[k] = v; save(); };

  // florr build version
  // florr serves a per-build hash (window.versionHash, also in the static.florr.io/<hash>/client.js
  // URL). it changes on every game update, so it's our signal for "the game changed under us".
  const KNOWN_VERSION = 'f73aca8408fb6cc409607f0ffe7c0e93aa88a4c5'; // build this menu was verified on
  function florrVer() {
    try { if (window.versionHash) return String(window.versionHash); } catch (e) {}
    const s = (document.querySelector('script[src*="static.florr.io"]') || {}).src || '';
    const m = s.match(/static\.florr\.io\/([a-f0-9]{8,})\//i); return m ? m[1] : '';
  }
  let VER = florrVer(); // re-read in startMenu(): at document-start versionHash may not be set yet
  const verShort = v => v ? v.slice(0, 7) : '?';

  // styles
  const css = `
  #fm-root, #fm-root *, #fm-db, #fm-db *, #fm-fab, #fm-warn, #fm-warn *, #fm-afklog, #fm-afklog *, #fm-about, #fm-about *, #fm-afkbanner { box-sizing:border-box; font-family:'Game','Ubuntu',system-ui,sans-serif; }
  #fm-about, #fm-afklog, #fm-afkbanner { paint-order:stroke fill; }
  #fm-root { position:fixed; top:70px; left:24px; width:340px; z-index:2147483600; color:${C.ink};
    -webkit-text-stroke:0.6px #000; paint-order:stroke fill; user-select:none; }
  #fm-panel { background:${C.panel}; border:3px solid ${C.panelEdge}; border-bottom-width:6px; border-radius:9px;
    overflow:hidden; box-shadow:0 6px 0 rgba(0,0,0,.18),0 10px 24px rgba(0,0,0,.35); }
  #fm-head { display:flex; align-items:center; gap:8px; padding:9px 11px; cursor:grab; background:${C.panelDark}; }
  #fm-head.drag { cursor:grabbing; }
  #fm-title { font-size:18px; flex:1; } #fm-title small { font-size:11px; opacity:.8; -webkit-text-stroke:0; margin-left:6px; }
  #fm-tabs { display:flex; gap:5px; padding:8px 9px 0; }
  .fm-tab { flex:1; padding:6px 4px; text-align:center; font-size:12px; white-space:nowrap; cursor:pointer; border-radius:7px 7px 0 0;
    background:${C.cell}; border:2px solid ${C.cellEdge}; border-bottom:0; opacity:.72; }
  .fm-tab.on { opacity:1; background:${C.panel}; }
  #fm-body { padding:10px 11px 12px; min-height:96px; max-height:56vh; overflow-y:auto; }
  #fm-body::-webkit-scrollbar { width:8px; } #fm-body::-webkit-scrollbar-thumb { background:${C.cellEdge}; border-radius:4px; }
  .fm-row { display:flex; align-items:center; gap:10px; padding:7px 8px; margin:5px 0; background:${C.cell};
    border:2px solid ${C.cellEdge}; border-radius:8px; min-height:38px; }
  .fm-row .lbl { flex:1; font-size:13px; } .fm-row .lbl .sub { display:block; font-size:10.5px; opacity:.75; -webkit-text-stroke:0; }
  .fm-btn { background:${C.green}; border:0; border-bottom:3px solid ${C.greenEdge}; color:#fff; border-radius:7px;
    padding:6px 12px; font-size:12.5px; cursor:pointer; -webkit-text-stroke:0.5px #000; }
  .fm-btn.gray { background:${C.gray}; border-bottom-color:${C.grayEdge}; }
  .fm-btn.red { background:${C.red}; border-bottom-color:${C.redEdge}; }
  .fm-btn:active { transform:translateY(2px); border-bottom-width:1px; }
  .fm-soon { text-align:center; font-size:12px; opacity:.6; -webkit-text-stroke:0; padding:32px 10px; }
  .fm-note { text-align:center; font-size:10.5px; opacity:.75; -webkit-text-stroke:0; padding:8px 4px 2px; }
  .fm-note.warn { color:#ffd24a; opacity:.95; }
  .fm-x { width:24px; height:24px; border-radius:6px; background:${C.red}; border:0; border-bottom:3px solid ${C.redEdge};
    color:#fff; font-size:14px; cursor:pointer; -webkit-text-stroke:0.6px #000; line-height:1; }
  .fm-x:active { transform:translateY(2px); border-bottom-width:1px; }
  #fm-fab { position:fixed; right:18px; bottom:18px; width:50px; height:50px; border-radius:50%; z-index:2147483600;
    background:${C.panel}; border:3px solid ${C.panelEdge}; border-bottom-width:5px; cursor:pointer;
    display:flex; align-items:center; justify-content:center; font-size:24px; box-shadow:0 4px 10px rgba(0,0,0,.4); }
  #fm-fab:active { transform:translateY(2px); }

  /* version-mismatch warning */
  #fm-warn { position:fixed; inset:0; z-index:2147483646; display:flex; align-items:center; justify-content:center;
    background:rgba(0,0,0,.55); color:${C.ink}; -webkit-text-stroke:0.5px #000; }
  #fm-warn-box { width:min(440px,92vw); background:${C.panel}; border:3px solid ${C.panelEdge}; border-bottom-width:6px;
    border-radius:12px; padding:18px 22px 20px; box-shadow:0 16px 50px rgba(0,0,0,.6); text-align:center; }
  .fm-warn-title { font-size:20px; margin-bottom:12px; }
  .fm-warn-body { font-size:13px; -webkit-text-stroke:0; line-height:1.5; opacity:.95; }
  .fm-warn-body b { color:#ffe9b0; }
  .fm-warn-btns { display:flex; gap:10px; justify-content:center; margin-top:18px; }

  /* database browser */
  #fm-db { position:fixed; inset:0; z-index:2147483640; display:none; align-items:center; justify-content:center;
    background:rgba(0,0,0,.4); color:${C.ink}; }
  #fm-db.open { display:flex; }
  #fm-db-panel { width:min(900px,95vw); height:84vh; background:${C.panel}; border:3px solid ${C.panelEdge}; border-bottom-width:6px;
    border-radius:12px; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 14px 44px rgba(0,0,0,.55); }
  #fm-db-head { display:flex; align-items:center; gap:9px; padding:11px 13px; background:${C.panelDark};
    -webkit-text-stroke:0.6px #000; paint-order:stroke fill; position:relative; z-index:3; }
  .fm-dd { position:relative; }
  .fm-dd-btn { background:${C.cell}; border:2px solid ${C.cellEdge}; border-bottom-width:3px; border-radius:8px;
    padding:6px 10px; font-size:13px; cursor:pointer; display:flex; align-items:center; gap:7px; white-space:nowrap; }
  .fm-dd-arr { margin-left:auto; font-size:10px; opacity:.85; -webkit-text-stroke:0; padding-left:4px; }
  .fm-dd-list { position:absolute; top:calc(100% + 4px); left:0; min-width:100%; background:${C.panelDark};
    border:2px solid ${C.cellEdge}; border-radius:8px; padding:4px; display:none; z-index:20; max-height:280px;
    overflow-y:auto; box-shadow:0 8px 20px rgba(0,0,0,.45); }
  .fm-dd-list.show { display:block; }
  .fm-dd-item { display:flex; align-items:center; gap:8px; padding:6px 10px; border-radius:6px; cursor:pointer; font-size:13px; white-space:nowrap; }
  .fm-dd-item:hover { background:${C.cell}; }
  .fm-dd-dot { width:11px; height:11px; border-radius:50%; border:1px solid rgba(0,0,0,.5); flex:0 0 auto; -webkit-text-stroke:0; }
  .fm-dd-list::-webkit-scrollbar { width:8px; } .fm-dd-list::-webkit-scrollbar-thumb { background:${C.cellEdge}; border-radius:4px; }
  .fm-kind { display:flex; gap:4px; }
  .fm-kind span { padding:7px 16px; border-radius:8px; background:${C.cell}; border:2px solid ${C.cellEdge}; border-bottom-width:3px;
    cursor:pointer; opacity:.7; font-size:14px; -webkit-text-stroke:0.6px #000; }
  .fm-kind span.on { opacity:1; background:${C.green}; border-color:${C.greenEdge}; }
  #fm-db-search { background:${C.cell}; border:2px solid ${C.cellEdge}; border-radius:8px; color:#fff; padding:7px 10px;
    width:160px; font-size:13px; -webkit-text-stroke:0.5px #000; outline:0; }
  #fm-db-search::placeholder { color:rgba(255,255,255,.6); -webkit-text-stroke:0; }
  .fm-dsel { background:${C.cell}; color:#fff; border:2px solid ${C.cellEdge}; border-bottom-width:3px; border-radius:8px;
    padding:6px 9px; font-size:13px; cursor:pointer; -webkit-text-stroke:0.5px #000; }
  .fm-dsel option { background:${C.panelDark}; -webkit-text-stroke:0; }
  #fm-db-count { font-size:11px; opacity:.85; -webkit-text-stroke:0; min-width:54px; text-align:right; }
  #fm-db-x { width:28px; height:28px; font-size:15px; }
  #fm-db-grid { flex:1; overflow-y:auto; padding:13px; display:grid; gap:11px; align-content:start;
    grid-template-columns:repeat(auto-fill,minmax(132px,1fr)); }
  #fm-db-grid::-webkit-scrollbar,#fm-db-detail::-webkit-scrollbar { width:9px; }
  #fm-db-grid::-webkit-scrollbar-thumb,#fm-db-detail::-webkit-scrollbar-thumb { background:${C.cellEdge}; border-radius:5px; }
  .fm-card { position:relative; background:${C.cell}; border:2px solid ${C.cellEdge}; border-bottom-width:4px; border-radius:10px;
    padding:11px 8px 9px; cursor:pointer; display:flex; flex-direction:column; align-items:center; gap:5px; text-align:center; transition:transform .08s; }
  .fm-card:hover { transform:translateY(-2px); }
  .fm-card img { width:60px; height:60px; object-fit:contain; }
  .fm-card.baked img { width:84px; height:84px; }
  .fm-card.baked { justify-content:space-between; }
  .fm-card .nm { font-size:13.5px; line-height:1.1; }
  .fm-card .id { position:absolute; top:5px; left:7px; font-size:10px; opacity:.5; -webkit-text-stroke:0; }
  .fm-pills { display:flex; gap:5px; flex-wrap:wrap; justify-content:center; margin-top:1px; }
  .fm-pill { display:inline-flex; align-items:center; gap:3px; font-size:11px; padding:2px 7px; border-radius:7px;
    background:rgba(0,0,0,.26); border:1.5px solid rgba(0,0,0,.28); -webkit-text-stroke:0.5px #000; }
  .fm-pill .k { font-size:8.5px; opacity:.92; letter-spacing:.2px; }
  .fm-pill.hp { background:rgba(108,207,99,.34); border-color:rgba(60,140,55,.5); }
  .fm-pill.dmg { background:rgba(228,116,76,.36); border-color:rgba(170,70,40,.5); }
  .fm-pill.pas { opacity:.85; }
  .fm-dots { display:flex; gap:3px; flex-wrap:wrap; justify-content:center; }
  .fm-dot { width:8px; height:8px; border-radius:50%; border:1px solid rgba(0,0,0,.45); }
  .fm-dot.on { width:11px; height:11px; box-shadow:0 0 0 1.5px #fff; }
  #fm-db-detail { flex:1; overflow-y:auto; padding:16px 20px; display:none; }
  .fm-back { cursor:pointer; font-size:13px; background:${C.gray}; border-bottom:3px solid ${C.grayEdge}; border-radius:7px;
    padding:6px 13px; -webkit-text-stroke:0.5px #000; display:inline-block; }
  .fm-back:active { transform:translateY(2px); border-bottom-width:1px; }
  .fm-dtop { display:flex; gap:18px; align-items:center; margin-top:13px; }
  .fm-dtop img { width:116px; height:116px; object-fit:contain; background:rgba(0,0,0,.13); border-radius:12px; padding:8px; }
  .fm-dname { font-size:26px; } .fm-dsub { font-size:12px; opacity:.7; -webkit-text-stroke:0; margin-top:2px; }
  .fm-desc { font-size:13.5px; -webkit-text-stroke:0; opacity:.95; line-height:1.45; white-space:pre-line; margin:14px 0 2px; max-width:660px; }
  .fm-flag { display:inline-block; font-size:11px; -webkit-text-stroke:0; background:rgba(0,0,0,.2); border-radius:5px; padding:3px 8px; margin:5px 5px 0 0; }
  .fm-rtabs { display:flex; gap:6px; flex-wrap:wrap; margin:16px 0 10px; }
  .fm-rtab { padding:5px 12px; border-radius:7px; cursor:pointer; font-size:12.5px; border:2px solid rgba(0,0,0,.35); border-bottom-width:3px; -webkit-text-stroke:0.5px #000; }
  .fm-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:7px 10px; }
  .fm-stat { display:flex; justify-content:space-between; padding:7px 11px; background:${C.cell}; border:2px solid ${C.cellEdge}; border-radius:8px; font-size:13px; }
  .fm-stat .v { -webkit-text-stroke:0; opacity:.95; }
  .fm-dh { font-size:12px; opacity:.7; -webkit-text-stroke:0; margin:18px 0 7px; text-transform:uppercase; letter-spacing:.6px; }
  .fm-drops { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:8px; }
  .fm-drop { display:flex; align-items:center; gap:9px; background:${C.cell}; border:2px solid ${C.cellEdge}; border-radius:8px; padding:7px; font-size:12.5px; }
  .fm-drop img { width:36px; height:36px; object-fit:contain; flex:0 0 auto; }
  .fm-drop .pct { font-size:11px; opacity:.7; -webkit-text-stroke:0; }

  /* AFK auto-solver: toggle, log box, banner */
  .fm-toggle { width:54px; height:28px; border-radius:15px; background:${C.grayEdge}; position:relative; cursor:pointer;
    flex:0 0 auto; transition:background .15s; border:2px solid rgba(0,0,0,.25); }
  .fm-toggle.on { background:${C.greenEdge}; }
  .fm-toggle::after { content:''; position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%;
    background:#fff; transition:left .15s; box-shadow:0 1px 2px rgba(0,0,0,.45); }
  .fm-toggle.on::after { left:28px; }
  #fm-afklog { position:fixed; inset:0; z-index:2147483641; display:none; align-items:center; justify-content:center; background:rgba(0,0,0,.4); color:${C.ink}; }
  #fm-afklog.open { display:flex; }
  #fm-afklog-box { width:min(560px,94vw); max-height:82vh; background:${C.panel}; border:3px solid ${C.panelEdge};
    border-bottom-width:6px; border-radius:12px; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 14px 44px rgba(0,0,0,.55); }
  #fm-afklog-head { display:flex; align-items:center; gap:9px; padding:11px 13px; background:${C.panelDark}; -webkit-text-stroke:0.6px #000; paint-order:stroke fill; }
  #fm-afklog-sum { font-size:11.5px; -webkit-text-stroke:0; opacity:.9; }
  #fm-afklog-list { flex:1; overflow-y:auto; padding:9px 11px; -webkit-text-stroke:0; }
  #fm-afklog-list::-webkit-scrollbar { width:8px; } #fm-afklog-list::-webkit-scrollbar-thumb { background:${C.cellEdge}; border-radius:4px; }
  .fm-logrow { display:flex; align-items:center; gap:10px; padding:8px 11px; margin:5px 0; border-radius:8px;
    background:rgba(0,0,0,.16); border-left:4px solid ${C.gray}; font-size:12.5px; }
  .fm-logrow.ok { border-left-color:${C.green}; } .fm-logrow.bad { border-left-color:${C.red}; } .fm-logrow.flap { border-left-color:#4d52e3; }
  .fm-logrow.flap .st { color:#9da1f2; }
  .fm-logrow .st { font-weight:bold; white-space:nowrap; } .fm-logrow.ok .st { color:#9bf08c; } .fm-logrow.bad .st { color:#ff8f8f; }
  .fm-logrow .meta { flex:1; opacity:.82; font-size:11px; }
  .fm-logrow .tm { opacity:.55; font-size:10.5px; white-space:nowrap; }
  .fm-logempty { text-align:center; opacity:.6; padding:34px 14px; font-size:12.5px; -webkit-text-stroke:0; }
  #fm-afkbanner { position:fixed; top:14px; left:50%; transform:translateX(-50%); background:${C.panelDark}; color:#fff;
    -webkit-text-stroke:0.5px #000; border:2px solid ${C.green}; border-radius:9px; padding:9px 16px; font-size:14px;
    z-index:2147483647; pointer-events:none; box-shadow:0 4px 14px rgba(0,0,0,.5); display:none; }

  /* info icon + about / contact */
  .fm-i { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%;
    background:${C.cell}; border:2px solid ${C.cellEdge}; border-bottom-width:3px; color:#fff; font-size:13px; font-weight:bold;
    cursor:pointer; -webkit-text-stroke:0; vertical-align:middle; margin-left:9px; line-height:1; flex:0 0 auto; }
  .fm-i:hover { background:${C.panelDark}; } .fm-i:active { transform:translateY(1px); border-bottom-width:2px; }
  #fm-about { position:fixed; inset:0; z-index:2147483646; display:none; align-items:center; justify-content:center;
    background:rgba(0,0,0,.5); color:${C.ink}; -webkit-text-stroke:0.5px #000; }
  #fm-about.open { display:flex; }
  #fm-about-box { width:min(384px,92vw); background:${C.panel}; border:3px solid ${C.panelEdge}; border-bottom-width:6px;
    border-radius:12px; padding:18px 20px 18px; box-shadow:0 16px 50px rgba(0,0,0,.6); text-align:center; }
  #fm-about-box h3 { font-size:19px; margin:0 0 4px; } #fm-about-box h3 small { font-size:12px; opacity:.7; -webkit-text-stroke:0; margin-left:5px; }
  #fm-about-box p { font-size:12.5px; -webkit-text-stroke:0; line-height:1.5; opacity:.92; margin:9px 2px; }
  #fm-about-box p.warn { color:#ffd24a; opacity:.95; }
  .fm-discord { display:inline-flex; align-items:center; gap:9px; margin:4px 0 2px; background:${C.cell};
    border:2px solid ${C.cellEdge}; border-bottom-width:3px; border-radius:9px; padding:9px 15px; font-size:15px;
    -webkit-text-stroke:0.5px #000; cursor:pointer; }
  .fm-discord:active { transform:translateY(2px); border-bottom-width:1px; }
  .fm-discord .cp { font-size:10px; opacity:.75; -webkit-text-stroke:0; background:rgba(0,0,0,.22); border-radius:5px; padding:2px 6px; }
  `;
  // stylesheet is injected in startMenu() below (we run at document-start, before <head> is guaranteed).

  // game data (from the wasm's _Util_* exports + bundled localization)
  function readCString(ptr) { const u = window.Module.HEAPU8; let e = ptr >>> 0; while (u[e]) e++; return new TextDecoder().decode(u.subarray(ptr >>> 0, e)); }
  let _petals = null, _mobs = null;
  function loadNd(fn) { const out = []; try { readCString(window.Module[fn]()).split('\n').forEach(l => { const s = l.trim(); if (s) try { out.push(JSON.parse(s)); } catch (e) {} }); } catch (e) {} return out; }
  // don't cache an EMPTY result: the menu can load before the game's Module is ready (race at
  // document-idle), so retry on each call until _Util_* returns real data, then cache it.
  function loadPetals() { if (!_petals || !_petals.length) _petals = (window.Module && window.Module._Util_GetPetals) ? loadNd('_Util_GetPetals') : []; return _petals; }
  function loadMobs() { if (!_mobs || !_mobs.length) _mobs = (window.Module && window.Module._Util_GetMobs) ? loadNd('_Util_GetMobs') : []; return _mobs; }

  // names + descriptions live in the wasm's localization DB (every language bundled; English is the first
  // block, so the first match for a key is English). read it straight out of the heap and cache.
  let _loca = null;
  function loca() {
    if (_loca && (Object.keys(_loca.petals).length || Object.keys(_loca.mobs).length)) return _loca; // retry until heap has data
    _loca = { petals: {}, mobs: {} };
    try {
      const u = window.Module.HEAPU8, L = u.length, dec = new TextDecoder();
      const idxOf = str => { const f = str.charCodeAt(0); for (let i = 0; i + str.length <= L; i++) { if (u[i] !== f) continue; let ok = 1; for (let j = 1; j < str.length; j++) if (u[i + j] !== str.charCodeAt(j)) { ok = 0; break; } if (ok) return i; } return -1; };
      const block = (prefix, into) => {
        const a = idxOf(prefix + '/'); if (a < 0) return;
        const chunk = dec.decode(u.subarray(a, Math.min(L, a + 200000)));
        // some entries (e.g. mjolnir) have a rarity-dependent name: a base "{#...}" template plus
        // per-rarity sub-keys (Petals/mjolnir/default/Name, .../unique/Name). capture both.
        const re = new RegExp(prefix + '\\/([a-z0-9_]+)(?:\\/([a-z0-9_]+))?\\/(Name|Description)=([^\\r\\n]*)', 'g'); let m;
        while ((m = re.exec(chunk))) {
          const s = m[1], sub = m[2], f = m[3].toLowerCase(), val = m[4], o = (into[s] = into[s] || {});
          if (sub) { const vs = (o.variants = o.variants || {}), v = (vs[sub] = vs[sub] || {}); if (v[f] == null) v[f] = val; }
          else if (o[f] == null) o[f] = val;
        }
      };
      block('Petals', _loca.petals); block('Mobs', _loca.mobs);
    } catch (e) {}
    return _loca;
  }
  const entryLoca = (kind, sid) => (kind === 'petals' ? loca().petals : loca().mobs)[sid] || {};
  const cleanDesc = d => d ? d.replace(/<n\/>/g, '\n').replace(/<[^>]+>/g, '').replace(/\{[^}]*\}/g, '').trim() : '';
  // resolve a name/description, handling rarity-dependent "{#...}" templates (mjolnir: Fragment / Mjölnir).
  function variantVal(kind, e, field, r) {
    const o = entryLoca(kind, e.sid); let v = o[field];
    if (v && v.indexOf('{#') !== -1) { const vs = o.variants || {}, rk = (RARITY[r] || '').toLowerCase(); v = (vs[rk] && vs[rk][field]) || (vs.default && vs.default[field]) || ''; }
    return v;
  }
  const dispName = (kind, e, r) => variantVal(kind, e, 'name', r == null ? maxR(e) : r) || pretty(e.sid);
  const descText = (kind, e, r) => cleanDesc(variantVal(kind, e, 'description', r == null ? maxR(e) : r));

  const RARITY = ['Common', 'Unusual', 'Rare', 'Epic', 'Legendary', 'Mythic', 'Ultra', 'Super', 'Unique', 'Tier 9'];
  const RAR_COL = ['#7eef6d', '#ffe65d', '#4d52e3', '#861fde', '#de1f1f', '#1fdbde', '#ff2b75', '#2bffca', '#ff5500', '#888'];
  const pretty = s => String(s || '').split('_').map(w => w ? w[0].toUpperCase() + w.slice(1) : w).join(' ');
  const lastKey = k => String(k).split('/').pop();
  const niceLabel = k => lastKey(k).replace(/([a-z0-9])([A-Z])/g, '$1 $2');
  const _ic = {};
  function tryIcon(fn, id, r) { try { return readCString(window.Module[fn](96, id, r)); } catch (e) { return ''; } }
  function petalIcon(id, r) { const k = 'p' + id + '.' + r; return k in _ic ? _ic[k] : (_ic[k] = tryIcon('_Util_GeneratePetalImage', id, r)); }
  function mobIcon(id, r) { const k = 'm' + id + '.' + r; return k in _ic ? _ic[k] : (_ic[k] = tryIcon('_Util_GenerateMobImage', id, r)); }
  function raritiesOf(e) { const out = []; (e.rarities || []).forEach((rr, r) => { if (rr && Object.keys(rr).length) out.push(r); }); return out.length ? out : [0]; }
  const maxR = e => raritiesOf(e).slice(-1)[0];
  const rarObj = (e, r) => (e.rarities || [])[r] || {};
  function attrVals(rr, name) { if (rr && rr.tooltip) for (const t of rr.tooltip) if (lastKey(t[0]) === name) return t.slice(1); return null; }
  const hpVals = rr => attrVals(rr, 'Health') || attrVals(rr, 'HealthRange');
  const lastNum = v => v ? +v[v.length - 1] : 0;
  const compact = n => { n = Math.round(+n); const a = Math.abs(n); if (a >= 1e6) return (Math.round(n / 1e5) / 10) + 'M'; if (a >= 1e4) return Math.round(n / 1e3) + 'k'; if (a >= 1e3) return (Math.round(n / 100) / 10) + 'k'; return '' + n; };
  const compactVals = v => v.map(compact).join('–');
  // tidy a value for the detail view: integers stay exact, floats round to 1 dp, non-numbers pass through.
  const fnum = x => { const n = +x; if (x === '' || x == null || !isFinite(n)) return x; return Number.isInteger(n) ? '' + n : '' + (Math.round(n * 10) / 10); };
  function statList(rr) {
    const out = [];
    if (rr && rr.tooltip) rr.tooltip.forEach(t => out.push([niceLabel(t[0]), t.slice(1).map(fnum).join(' – ')]));
    if (rr && rr.reloadTime != null) out.push(['Reload', fnum(rr.reloadTime / 1000) + 's']);
    if (rr && rr.exp != null) out.push(['EXP', '' + rr.exp]);
    return out;
  }
  // which rarity to show for an entry given the global selector ('max' or an index)
  function viewR(e) { const rs = raritiesOf(e); if (dbState.rar === 'max') return rs[rs.length - 1]; const r = +dbState.rar; return rs.includes(r) ? r : rs[rs.length - 1]; }

  // database browser
  const dbState = { kind: 'petals', q: '', sort: 'id', rar: 'max' };
  let dbEl = null, gridEl, detailEl, searchEl, sortSlot, rarSlot, countEl;

  // small custom dropdown so rarity items can carry a colour dot (native <select> can't do that).
  function dropdown(items, current, onPick) {
    const dd = document.createElement('div'); dd.className = 'fm-dd';
    const btn = document.createElement('div'); btn.className = 'fm-dd-btn';
    const list = document.createElement('div'); list.className = 'fm-dd-list';
    const dot = c => c ? `<span class="fm-dd-dot" style="background:${c}"></span>` : '';
    const draw = () => { const c = items.find(i => i.value === current) || items[0]; btn.innerHTML = dot(c.color) + `<span>${c.label}</span><span class="fm-dd-arr">▾</span>`; };
    items.forEach(it => { const el = document.createElement('div'); el.className = 'fm-dd-item'; el.innerHTML = dot(it.color) + `<span>${it.label}</span>`; el.onclick = ev => { ev.stopPropagation(); current = it.value; draw(); list.classList.remove('show'); onPick(it.value); }; list.appendChild(el); });
    btn.onclick = ev => { ev.stopPropagation(); list.classList.toggle('show'); };
    document.addEventListener('mousedown', ev => { if (!dd.contains(ev.target)) list.classList.remove('show'); });
    dd.append(btn, list); draw(); return dd;
  }

  function buildDB() {
    dbEl = document.createElement('div'); dbEl.id = 'fm-db';
    dbEl.innerHTML = `<div id="fm-db-panel">
      <div id="fm-db-head">
        <div class="fm-kind"><span data-k="petals">Petals</span><span data-k="mobs">Mobs</span></div>
        <input id="fm-db-search" placeholder="search…" spellcheck="false">
        <span id="fm-db-rar-slot"></span>
        <span id="fm-db-sort-slot"></span>
        <div style="flex:1"></div><span id="fm-db-count"></span>
        <button class="fm-x" id="fm-db-x">✕</button>
      </div>
      <div id="fm-db-grid"></div>
      <div id="fm-db-detail"></div>
    </div>`;
    document.body.appendChild(dbEl);
    gridEl = dbEl.querySelector('#fm-db-grid'); detailEl = dbEl.querySelector('#fm-db-detail');
    searchEl = dbEl.querySelector('#fm-db-search'); countEl = dbEl.querySelector('#fm-db-count');
    rarSlot = dbEl.querySelector('#fm-db-rar-slot'); sortSlot = dbEl.querySelector('#fm-db-sort-slot');
    ['keydown', 'keyup', 'keypress'].forEach(ev => dbEl.addEventListener(ev, e => e.stopPropagation()));
    dbEl.addEventListener('mousedown', e => { if (e.target === dbEl) closeDB(); });
    dbEl.querySelector('#fm-db-x').onclick = closeDB;
    dbEl.querySelectorAll('.fm-kind span').forEach(s => s.onclick = () => { dbState.kind = s.dataset.k; dbState.q = ''; searchEl.value = ''; renderDB(); });
    // florr swallows key events at the window, so the box never types on its own. preventDefault still
    // lets our own keydown run, so we maintain the value ourselves and re-filter.
    searchEl.addEventListener('keydown', e => {
      e.stopPropagation();
      if (e.ctrlKey || e.metaKey || e.altKey) return;
      let v = searchEl.value;
      if (e.key === 'Backspace') v = v.slice(0, -1);
      else if (e.key === 'Escape') { v = ''; searchEl.blur(); }
      else if (e.key.length === 1) v += e.key;
      else return;
      e.preventDefault(); searchEl.value = v; dbState.q = v.trim().toLowerCase(); renderGrid();
    });
    searchEl.addEventListener('paste', e => { e.stopPropagation(); e.preventDefault(); const t = ((e.clipboardData || window.clipboardData).getData('text') || ''); searchEl.value += t; dbState.q = searchEl.value.trim().toLowerCase(); renderGrid(); });
  }
  const showGrid = () => { detailEl.style.display = 'none'; gridEl.style.display = 'grid'; };
  const showDetail = () => { gridEl.style.display = 'none'; detailEl.style.display = 'block'; };

  function renderDB() {
    const k = dbState.kind, data = k === 'petals' ? loadPetals() : loadMobs();
    dbEl.querySelectorAll('.fm-kind span').forEach(s => s.classList.toggle('on', s.dataset.k === k));
    const opts = k === 'petals'
      ? [['id', 'ID'], ['name', 'Name'], ['rarity', 'Max rarity'], ['damage', 'Damage'], ['health', 'Health'], ['reload', 'Reload']]
      : [['id', 'ID'], ['name', 'Name'], ['health', 'Health'], ['damage', 'Damage'], ['exp', 'EXP']];
    if (!opts.some(o => o[0] === dbState.sort)) dbState.sort = 'id';
    sortSlot.innerHTML = '';
    sortSlot.appendChild(dropdown(opts.map(o => ({ value: o[0], label: 'Sort: ' + o[1] })), dbState.sort, v => { dbState.sort = v; renderGrid(); }));
    // rarity dropdown: Max + every tier present, each with its colour dot
    let top = 0; data.forEach(e => { const m = maxR(e); if (m > top) top = m; });
    if (dbState.rar !== 'max' && +dbState.rar > top) dbState.rar = 'max';
    const rarItems = [{ value: 'max', label: 'Max rarity' }];
    for (let r = 0; r <= top; r++) rarItems.push({ value: String(r), label: RARITY[r] || ('Tier ' + r), color: RAR_COL[r] || '#888' });
    rarSlot.innerHTML = '';
    rarSlot.appendChild(dropdown(rarItems, dbState.rar, v => { dbState.rar = v; renderGrid(); }));
    showGrid(); renderGrid();
  }

  function renderGrid() {
    const k = dbState.kind, data = k === 'petals' ? loadPetals() : loadMobs();
    let list = data.slice();
    if (dbState.q) { const q = dbState.q; list = list.filter(e => dispName(k, e).toLowerCase().includes(q) || String(e.sid).toLowerCase().includes(q) || String(e.id) === q); }
    const at = e => rarObj(e, viewR(e));
    const sortVal = {
      id: e => e.id, name: e => pretty(e.sid), rarity: e => maxR(e),
      damage: e => lastNum(attrVals(at(e), 'Damage')), health: e => lastNum(hpVals(at(e))),
      reload: e => rarObj(e, raritiesOf(e)[0]).reloadTime || 0, exp: e => at(e).exp || 0
    }[dbState.sort] || (e => e.id);
    if (dbState.sort === 'name') list.sort((a, b) => sortVal(a).localeCompare(sortVal(b)));
    else if (dbState.sort === 'id') list.sort((a, b) => a.id - b.id);
    else list.sort((a, b) => (sortVal(b) - sortVal(a)) || (a.id - b.id));
    countEl.textContent = list.length + ' / ' + data.length;
    gridEl.innerHTML = '';
    const frag = document.createDocumentFragment();
    list.forEach(e => {
      const r = viewR(e), rr = rarObj(e, r);
      const ic = k === 'petals' ? petalIcon(e.id, r) : mobIcon(e.id, r);
      const dots = raritiesOf(e).map(rx => `<span class="fm-dot${rx === r ? ' on' : ''}" style="background:${RAR_COL[rx] || '#aaa'}" title="${RARITY[rx] || rx}"></span>`).join('');
      const hpV = hpVals(rr), dmgV = attrVals(rr, 'Damage');
      let pills = '';
      if (hpV) pills += `<span class="fm-pill hp"><span class="k">HP</span>${compactVals(hpV)}</span>`;
      if (dmgV) pills += `<span class="fm-pill dmg"><span class="k">DMG</span>${compactVals(dmgV)}</span>`;
      if (!pills && e.isPassive) pills = `<span class="fm-pill pas">Passive</span>`;
      const baked = k === 'petals';  // petal icons already render the name; mob icons don't
      const card = document.createElement('div'); card.className = 'fm-card' + (baked ? ' baked' : '');
      const nm = baked ? '' : `<div class="nm">${dispName(k, e, r)}</div>`;
      card.innerHTML = `<span class="id">#${e.id}</span><img src="${ic}">${nm}<div class="fm-dots">${dots}</div>${pills ? `<div class="fm-pills">${pills}</div>` : ''}`;
      card.onclick = () => openDetail(e);
      frag.appendChild(card);
    });
    gridEl.appendChild(frag);
  }

  function openDetail(e) {
    const k = dbState.kind, rs = raritiesOf(e);
    let cur = viewR(e);
    showDetail(); detailEl.scrollTop = 0;
    function paint() {
      const rr = rarObj(e, cur);
      const ic = k === 'petals' ? petalIcon(e.id, cur) : mobIcon(e.id, cur);
      const flags = k === 'petals' ? [e.isPassive && 'Passive', rr.droppable && 'Droppable', rr.shoppable && 'Buyable'].filter(Boolean) : [];
      const stats = statList(rr);
      let drops = '';
      if (k === 'mobs') {
        const ds = e.drops || [];
        if (ds.length) drops = `<div class="fm-dh">Drops</div><div class="fm-drops">` + ds.map(dr => { const p = loadPetals().find(x => x.id === dr.type); return `<div class="fm-drop"><img src="${p ? petalIcon(p.id, raritiesOf(p)[0]) : ''}"><div>${p ? pretty(p.sid) : '#' + dr.type}<div class="pct">${(dr.baseChance * 100).toFixed(2)}% base</div></div></div>`; }).join('') + `</div>`;
      } else {
        const src = loadMobs().filter(m => (m.drops || []).some(dr => dr.type === e.id));
        if (src.length) drops = `<div class="fm-dh">Dropped by</div><div class="fm-drops">` + src.map(m => { const dr = m.drops.find(x => x.type === e.id); return `<div class="fm-drop"><img src="${mobIcon(m.id, raritiesOf(m)[0])}"><div>${pretty(m.sid)}<div class="pct">${(dr.baseChance * 100).toFixed(2)}% base</div></div></div>`; }).join('') + `</div>`;
      }
      detailEl.innerHTML = `<span class="fm-back">← back</span>
        <div class="fm-dtop"><img src="${ic}"><div>
          <div class="fm-dname">${dispName(k, e, cur)}</div>
          <div class="fm-dsub">#${e.id} · ${e.sid}</div>
          <div>${flags.map(f => `<span class="fm-flag">${f}</span>`).join('')}</div>
        </div></div>
        <div class="fm-desc"></div>
        <div class="fm-rtabs">${rs.map(r => `<span class="fm-rtab" data-r="${r}" style="background:${cur === r ? (RAR_COL[r] || '#888') : 'rgba(0,0,0,.16)'};border-color:${cur === r ? 'rgba(0,0,0,.4)' : 'rgba(0,0,0,.25)'}">${RARITY[r] || ('T' + r)}</span>`).join('')}</div>
        <div class="fm-grid2">${stats.length ? stats.map(s => `<div class="fm-stat"><span>${s[0]}</span><span class="v">${s[1]}</span></div>`).join('') : '<div class="fm-dsub">no listed stats at this rarity</div>'}</div>
        ${drops}`;
      detailEl.querySelector('.fm-desc').textContent = descText(k, e, cur);
      detailEl.querySelector('.fm-back').onclick = showGrid;
      detailEl.querySelectorAll('.fm-rtab').forEach(t => t.onclick = () => { cur = +t.dataset.r; paint(); });
    }
    paint();
  }

  function openDB(kind) {
    if (!window.Module || !window.Module._Util_GetPetals) { toast('game still loading…'); return; }
    dbState.kind = kind || dbState.kind;
    if (!dbEl) buildDB();
    dbEl.classList.add('open'); renderDB();
  }
  function closeDB() { if (dbEl) dbEl.classList.remove('open'); }

  // afk auto-solver
  const _afkHx = h => { const m = String(h).match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null; };
  const _afkSat = c => { if (!c) return 0; const mx = Math.max(c[0], c[1], c[2]), mn = Math.min(c[0], c[1], c[2]); return mx ? (mx - mn) / mx : 0; };
  const _afkBlk = c => c && c[0] < 45 && c[1] < 45 && c[2] < 45, _afkWht = c => c && c[0] > 200 && c[1] > 200 && c[2] > 200;
  // afkFindDot(near, col): returns {active:false} | {active:true,dot:null} | {active:true,dot,r,col,panel}.
  // When tracking the dot mid-drag, pass its last position + colour so it stays locked on the SAME circle
  // (the dot moves continuously in small steps) and doesn't snap onto a petal/mob bleeding through the
  // see-through panel. Without args (first detection) it picks the vivid circle nearest the panel centre.
  function afkFindDot(near, col) {
    const a = window.__afk2; if (!a || !a.captured) return null; const C = a.captured.cmds;
    const ap = (T, x, y) => T && T.length === 6 ? [Math.round(T[0] * x + T[2] * y + T[4]), Math.round(T[1] * x + T[3] * y + T[5])] : [x, y];
    const sc = T => T && T.length === 6 ? Math.hypot(T[0], T[1]) : 1;
    let pend = null; const circ = []; let txt = null;
    for (const c of C) { const m = c[0];
      if (m === 'arc') pend = { p: ap(c[c.length - 1], c[1], c[2]), r: Math.round(c[3] * sc(c[c.length - 1])) };
      else if ((m === 'fill' || m === 'stroke') && pend) { circ.push({ p: pend.p, r: pend.r, col: c[1] }); pend = null; }
      else if (m === 'fillText' && /AFK Check|Drag the circle|Flap until/i.test(String(c[1]))) txt = ap(c[5], c[2], c[3]);
    }
    if (!txt) return { active: false };
    const small = circ.filter(c => c.r <= 9);
    const fl = circ.filter(c => c.r >= 14 && small.some(s => _afkBlk(_afkHx(s.col)) && Math.hypot(s.p[0] - c.p[0], s.p[1] - c.p[1]) < c.r) && small.some(s => _afkWht(_afkHx(s.col)) && Math.hypot(s.p[0] - c.p[0], s.p[1] - c.p[1]) < c.r));
    const inP = p => Math.abs(p[0] - txt[0]) < 460 && p[1] > txt[1] - 60 && p[1] < txt[1] + 560;
    // Exclude only the flower's BODY (its own radius), NOT a 150px halo — the dot can sit right next to the
    // player (we saw a cyan dot ~56px from the flower get wrongly dropped). Petals/mobs are filtered by MOTION
    // in afkFindStableDot(), not by distance to the flower.
    const cand = circ.filter(c => c.r >= 11 && c.r <= 32 && _afkSat(_afkHx(c.col)) > 0.4 && inP(c.p) && !fl.some(f => Math.hypot(c.p[0] - f.p[0], c.p[1] - f.p[1]) < f.r + 14));
    const ded = []; cand.forEach(c => { if (!ded.some(d => Math.hypot(d.p[0] - c.p[0], d.p[1] - c.p[1]) < 10)) ded.push(c); });
    if (!ded.length) return { active: true, dot: null, cands: [], panel: txt };
    const ref = near || [txt[0] + 120, txt[1] + 230];
    const cd = (h1, h2) => { const x = _afkHx(h1), y = _afkHx(h2); return x && y ? Math.abs(x[0] - y[0]) + Math.abs(x[1] - y[1]) + Math.abs(x[2] - y[2]) : 0; };
    const score = c => Math.hypot(c.p[0] - ref[0], c.p[1] - ref[1]) + (col && cd(c.col, col) > 80 ? 400 : 0); // prefer near + same colour
    ded.sort((a, b) => score(a) - score(b));
    return { active: true, dot: ded[0].p, r: ded[0].r, col: ded[0].col, panel: txt, cands: ded };
  }
  // Robustly identify the draggable dot at the START: read candidates twice ~280ms apart. The dot sits STILL
  // at the tunnel start; petals orbit the flower and mobs wander, so they shift between reads. Pick the
  // most-static candidate nearest the panel centre; fall back to nearest-centre if nothing is clearly static.
  async function afkFindStableDot() {
    const a = afkFindDot(); if (!a || !a.active) return a; if (!a.cands || !a.cands.length) return { active: true, dot: null };
    await afkSleep(280);
    const b = afkFindDot(); if (!b || !b.active) return b; if (!b.cands || !b.cands.length) return a;
    const txt = b.panel, cx = txt[0] + 120, cy = txt[1] + 230;
    const nearestPrev = c => a.cands.reduce((m, d) => Math.min(m, Math.hypot(d.p[0] - c.p[0], d.p[1] - c.p[1])), 1e9);
    const stable = b.cands.filter(c => nearestPrev(c) < 8); // barely moved in 280ms => static (the dot)
    const pick = (stable.length ? stable : b.cands).slice().sort((x, y) => Math.hypot(x.p[0] - cx, x.p[1] - cy) - Math.hypot(y.p[0] - cx, y.p[1] - cy))[0];
    return { active: true, dot: pick.p, r: pick.r, col: pick.col, panel: txt };
  }

  // Sleep driven by the worker-backed requestAnimationFrame + performance.now(), NOT setTimeout. Chrome
  // throttles a hidden tab's setTimeout to ~1/second (and ~1/minute after 5 min hidden), which would make
  // the drag take minutes and let the AFK check time out. Our rAF is worker-driven (not throttled), so this
  // stays accurate while minimized. setTimeout fallback guarantees we never hang if rAF ever stalls.
  const afkSleep = ms => new Promise(res => {
    let done = false; const fin = () => { if (!done) { done = true; res(); } };
    const t0 = performance.now();
    (function tick() { if (done) return; if (performance.now() - t0 >= ms) fin(); else requestAnimationFrame(tick); })();
    setTimeout(fin, ms + 2000);
  });
  // the main game canvas (largest, in case florr also keeps offscreen canvases)
  function afkCanvas() { const cs = [...document.querySelectorAll('canvas')]; if (!cs.length) return null; return cs.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0]; }
  function afkCanvasRect() { const c = afkCanvas(); if (!c) return null; const r = c.getBoundingClientRect(); return { left: r.left, top: r.top, sx: c.width / r.width || 1, sy: c.height / r.height || 1 }; }
  // failure-capture helpers (so a check the solver couldn't handle can be fully reconstructed)
  function afkSnapCmds() { try { return window.__afk2 && window.__afk2.captured ? JSON.parse(JSON.stringify(window.__afk2.captured.cmds)) : null; } catch (e) { return null; } }
  // both variants draw "AFK Check", so key off the instruction line: "Flap until..." => flap, "Drag the circle" => drag.
  function afkVariant() { try { let drag = false; for (const c of window.__afk2.captured.cmds) if (c[0] === 'fillText') { const t = String(c[1]); if (/Flap until/i.test(t)) return 'flap'; if (/Drag the circle/i.test(t)) drag = true; } return drag ? 'drag' : 'unknown'; } catch (e) {} return 'unknown'; }
  // crop a PNG of the panel region — the only way to see the tunnel (it isn't in the draw stream). Canvas2D is
  // readable so this works even minimized. box recorded so canvas-px coords map onto the crop.
  function afkShot(panel) {
    try {
      const cv = afkCanvas(); if (!cv) return null;
      const bx = Math.max(0, Math.round((panel ? panel[0] : cv.width / 2) - 490)), by = Math.max(0, Math.round((panel ? panel[1] : cv.height / 2) - 110));
      const bw = Math.min(980, cv.width - bx), bh = Math.min(720, cv.height - by);
      const oc = document.createElement('canvas'); oc.width = bw; oc.height = bh;
      oc.getContext('2d').drawImage(cv, bx, by, bw, bh, 0, 0, bw, bh);
      return { url: oc.toDataURL('image/png'), box: [bx, by, bw, bh] };
    } catch (e) { return null; }
  }
  // dispatch a synthetic (untrusted) mouse + pointer event at a draw-stream (device px) coordinate
  function afkMouse(type, devX, devY) {
    const rc = afkCanvasRect(); if (!rc) return; const cx = rc.left + devX / rc.sx, cy = rc.top + devY / rc.sy;
    const cv = afkCanvas(); if (!cv) return;
    const opt = { clientX: cx, clientY: cy, screenX: cx, screenY: cy, button: 0, buttons: type === 'mouseup' ? 0 : 1, bubbles: true, cancelable: true, view: window };
    try { cv.dispatchEvent(new PointerEvent({ mousedown: 'pointerdown', mousemove: 'pointermove', mouseup: 'pointerup' }[type], { ...opt, pointerId: 1, pointerType: 'mouse', isPrimary: true })); } catch (e) {}
    cv.dispatchEvent(new MouseEvent(type, opt)); window.dispatchEvent(new MouseEvent(type, opt));
  }
  // synthetic Space tap (keydown now, keyup ~3 frames later) — used to start/flap the flappy check
  function afkTapSpace() {
    const mk = type => new KeyboardEvent(type, { code: 'Space', key: ' ', keyCode: 32, which: 32, bubbles: true, cancelable: true, view: window });
    window.dispatchEvent(mk('keydown')); document.dispatchEvent(mk('keydown'));
    let n = 0; (function up() { if (n++ < 3) { requestAnimationFrame(up); return; } window.dispatchEvent(mk('keyup')); document.dispatchEvent(mk('keyup')); })();
  }

  // input lock: while solving, swallow the user's REAL (isTrusted) input so it can't fight the auto-solve;
  // our synthetic events (isTrusted === false) pass through. `keys` also blocks the keyboard (for flappy,
  // where we drive Space ourselves). Shows a banner.
  const _afkMouseTypes = ['mousedown', 'mousemove', 'mouseup', 'pointerdown', 'pointermove', 'pointerup', 'wheel', 'click', 'contextmenu', 'dragstart'];
  const _afkKeyTypes = ['keydown', 'keyup', 'keypress'];
  let _afkBlocker = null, _afkBanner = null, _afkBlocked = [];
  function afkLockInput(on, keys, label) {
    if (on) {
      if (!_afkBlocker) { _afkBlocker = e => { if (e.isTrusted) { e.stopImmediatePropagation(); e.preventDefault(); } }; _afkBlocked = keys ? _afkMouseTypes.concat(_afkKeyTypes) : _afkMouseTypes; _afkBlocked.forEach(t => window.addEventListener(t, _afkBlocker, true)); }
      if (!_afkBanner) { _afkBanner = document.createElement('div'); _afkBanner.id = 'fm-afkbanner'; (document.body || document.documentElement).appendChild(_afkBanner); }
      _afkBanner.textContent = label || '🛡 Solving AFK check… (input locked)'; _afkBanner.style.display = 'block';
    } else {
      if (_afkBlocker) { _afkBlocked.forEach(t => window.removeEventListener(t, _afkBlocker, true)); _afkBlocker = null; _afkBlocked = []; }
      if (_afkBanner) _afkBanner.style.display = 'none';
    }
  }

  // solve log. Each entry is a summary; FAILED entries also carry a heavy `.dump` (cmds + trail + screenshots)
  // for reconstruction, kept only for the most recent few to bound memory.
  const afkLog = []; // newest first
  function afkStats() { return { ok: afkLog.filter(e => e.ok).length, total: afkLog.length }; }
  function afkBuildRecord(ok, why, s0, end, steps, t0, variant, cap0, shot0, trail) {
    const cv = afkCanvas();
    const rec = {
      t: Date.now(), ok: !!ok, why: why || '', variant, build: VER, steps, ms: Date.now() - t0,
      dot: s0.dot, r: s0.r, panel: s0.panel, end: [Math.round(end[0]), Math.round(end[1])],
      canvas: cv ? [cv.width, cv.height] : null, view: [innerWidth, innerHeight], dpr: window.devicePixelRatio || 1
    };
    if (!ok) rec.dump = { cmds: cap0, trail, shotStart: shot0, shotEnd: afkShot(s0.panel) }; // full reconstruction data
    return rec;
  }
  function afkRecord(rec) {
    afkLog.unshift(rec); if (afkLog.length > 60) afkLog.pop();
    let kept = 0; for (const e of afkLog) { if (e.dump && ++kept > 6) delete e.dump; } // bound memory: heavy data on recent failures only
    renderAfkLog(); const tabSum = document.getElementById('fm-afk-sum'); if (tabSum) { const s = afkStats(); tabSum.textContent = s.ok + '/' + s.total + ' solved'; }
    toast(rec.variant === 'flap' ? 'Flappy check recorded - Export & send it to the dev' : (rec.ok ? 'AFK check solved ✓' : 'AFK solve failed ✗ — logged for export'));
  }

  // the solve: feel-follow the tunnel with synthetic drag. Records a full trail + (on failure) the pristine
  // capture + screenshots so the exported log can fully reconstruct a check it couldn't handle.
  let _afkSolving = false;
  async function afkSolve() {
    if (_afkSolving) return; _afkSolving = true;
    const s0 = await afkFindStableDot(); // motion-based ID: the still circle = the dot, not orbiting petals / wandering mobs
    if (!s0 || !s0.active || !s0.dot) { _afkSolving = false; return; } // no clear dot this round; poll retries in ~1.4s
    afkLockInput(true);
    const cap0 = afkSnapCmds(), shot0 = afkShot(s0.panel), variant = afkVariant(); // snapshot the pristine challenge first
    let cur = s0.dot.slice(), R = s0.r || 18, dotCol = s0.col, panel = s0.panel, steps = 0, solved = false, recorded = false, why = 'ran_out';
    let dir = [1, 0], lead = Math.max(30, R * 1.7), maxJump = lead * 2; const t0 = Date.now(); const trail = []; // maxJump: a single drag step can't move the dot far, so a bigger "move" is a mis-detection
    try {
      afkMouse('mousemove', cur[0], cur[1]); await afkSleep(50); afkMouse('mousedown', cur[0], cur[1]); await afkSleep(130);
      dir = [(panel[0] + 120) - cur[0], (panel[1] + 230) - cur[1]]; const dl = Math.hypot(dir[0], dir[1]) || 1; dir = [dir[0] / dl, dir[1] / dl];
      const angs = [0, 25, -25, 50, -50, 75, -75, 100, -100]; let noProg = 0;
      for (let step = 0; step < 75 && !solved; step++) {
        if (Date.now() - t0 > 12000) { why = 'timeout'; break; } // hard cap: never lock input for too long
        let adv = false; const base = Math.atan2(dir[1], dir[0]); const tr = { s: step, p: [Math.round(cur[0]), Math.round(cur[1])], t: [] };
        for (const da of angs) {
          const ang = base + da * Math.PI / 180, nd = [Math.cos(ang), Math.sin(ang)];
          afkMouse('mousemove', cur[0] + nd[0] * lead, cur[1] + nd[1] * lead); await afkSleep(150);
          const r2 = afkFindDot(cur, dotCol); // track the SAME dot (nearest + same colour), not whatever is near centre
          tr.t.push({ a: da, d: r2 ? (r2.active === false ? 'gone' : (r2.dot ? [Math.round(r2.dot[0]), Math.round(r2.dot[1])] : null)) : 'nocap' });
          if (r2 && r2.active === false) { solved = true; tr.o = 'solved'; break; }
          if (r2 && r2.dot) { const mv = Math.hypot(r2.dot[0] - cur[0], r2.dot[1] - cur[1]); if (mv > 5 && mv < maxJump) { dir = [(r2.dot[0] - cur[0]) / mv, (r2.dot[1] - cur[1]) / mv]; cur = r2.dot.slice(); steps++; adv = true; tr.o = 'advance'; tr.mv = Math.round(mv); break; } }
        }
        if (!solved && !adv) { // at/near the end: shove hard past the end to seat the dot in the end circle
          afkMouse('mousemove', cur[0] + dir[0] * lead * 3, cur[1] + dir[1] * lead * 3); await afkSleep(320);
          const r3 = afkFindDot(cur, dotCol); const mv3 = r3 && r3.dot ? Math.hypot(r3.dot[0] - cur[0], r3.dot[1] - cur[1]) : 0;
          if (r3 && r3.active === false) { solved = true; tr.o = 'solved_push'; }
          else if (r3 && r3.dot && mv3 > 5 && mv3 < lead * 4) { cur = r3.dot.slice(); steps++; noProg = 0; tr.o = 'push'; }
          else { tr.o = 'stuck'; if (++noProg > 2) { why = 'stuck_no_forward_dir'; trail.push(tr); break; } }
        } else if (adv) noProg = 0;
        trail.push(tr);
      }
      if (!solved) for (let k = 0; k < 6; k++) { const a = afkFindDot(); if (a && a.active === false) { solved = true; break; } afkMouse('mousemove', cur[0] + dir[0] * lead * 2.2, cur[1] + dir[1] * lead * 2.2); await afkSleep(90); }
      await afkSleep(120); afkMouse('mouseup', cur[0] + dir[0] * lead * 2.2, cur[1] + dir[1] * lead * 2.2);
      await afkSleep(800); const after = afkFindDot(); if (after) solved = (after.active === false);
      // "AFK text gone" alone isn't proof of a solve: a timeout/kick also removes it. Only count it solved if
      // the dot was actually dragged a real distance. Otherwise it's a timeout/kick (or a detection failure).
      const moved = Math.hypot(cur[0] - s0.dot[0], cur[1] - s0.dot[1]);
      const genuine = solved && moved > 35;
      if (genuine) why = '';
      else if (solved) why = 'vanished_no_progress'; // text disappeared but the dot barely moved => timed out / kicked
      else if (steps === 0 && why === 'ran_out') why = 'dot_never_moved';
      else if (why === 'ran_out') why = 'finished_unsolved';
      afkRecord(afkBuildRecord(genuine, why, s0, cur, steps, t0, variant, cap0, shot0, trail)); recorded = true;
    } catch (e) { why = 'error:' + (e && e.message); }
    finally {
      if (!recorded) { try { afkMouse('mouseup', cur[0], cur[1]); } catch (e) {} afkRecord(afkBuildRecord(false, why, s0, cur, steps, t0, variant, cap0, shot0, trail)); }
      afkLockInput(false); await afkSleep(3500); _afkSolving = false;
    }
  }
  function afkExport() {
    if (!afkLog.length) { toast('No solves to export yet.'); return; }
    const payload = {
      _readme: 'florr-menu AFK-solver log. "solves" newest-first. DRAG failures include "dump": {cmds: raw Canvas2D draw frame (each = [op, ...args, transform a,b,c,d,e,f]; ops incl arc/rect/fillRect/ellipse/drawImage/paths/fill/stroke/fillText -> exact geometry), trail: per-step decisions (p=dot pos, t=[{a:angle,d:dot-after}], o=outcome), shotStart/shotEnd: PNG dataURL panel crops {url, box:[x,y,w,h]}}. FLAPPY checks (variant:"flap", unsupported) include dump:{flap:true, frames:[{ms,cmds}] sampled over the session, shots:[{ms,url,box}]} so the bird+bars motion can be reconstructed. All coords are canvas device px.',
      tool: 'florr-menu', version: '0.5', exportedAt: new Date().toISOString(), build: VER,
      ua: navigator.userAgent, screen: { w: screen.width, h: screen.height, dpr: window.devicePixelRatio, innerW: innerWidth, innerH: innerHeight },
      stats: afkStats(), solves: afkLog
    };
    let json; try { json = JSON.stringify(payload); } catch (e) { toast('Export failed: ' + e.message); return; }
    const a = document.createElement('a'), url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
    a.href = url; a.download = 'florr-afk-logs-' + Date.now() + '.json'; document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 6000);
    const fails = afkLog.filter(e => !e.ok).length;
    toast('Exported ' + afkLog.length + ' solve(s) · ' + fails + ' failed — send the file to @kw0d932');
  }

  // The "Flap until the end" variant isn't solvable by dragging, so we do NOT run the drag solver on it.
  // Instead: press Space to START it (the bars only appear once started), keep the bird alive with a gentle
  // flap rhythm, and record frames + screenshots over the session. This one check fails, but the dump lets a
  // flappy solver be built from real bar layout + fall/flap physics. Locks input so it can't be interfered with.
  let _afkFlap = false;
  async function afkHandleFlap(s0) {
    if (_afkFlap) return; _afkFlap = true;
    afkLockInput(true, true, '⚑ Flappy check - auto-starting & recording (this one will fail; sending data to dev)');
    const frames = [], shots = [], t0 = performance.now(), cv = afkCanvas();
    toast('Flappy check - auto-starting & recording for the dev (this check will fail)');
    try {
      // START it so the bars appear. Space is the likely key, but hedge with a click too (one-shot data is precious).
      await afkSleep(150); afkTapSpace();
      const scx = s0.panel[0] + 120, scy = s0.panel[1] + 230; afkMouse('mousedown', scx, scy); await afkSleep(50); afkMouse('mouseup', scx, scy);
      await afkSleep(150);
      let lastFlap = performance.now();
      for (let i = 0; performance.now() - t0 < 11000; i++) {
        const s = afkFindDot(); if (!s || !s.active) break; // check ended (likely failed)
        if (frames.length < 55) { const c = afkSnapCmds(); if (c) frames.push({ ms: Math.round(performance.now() - t0), cmds: c }); }
        if (shots.length < 6 && i % 7 === 0) { const sh = afkShot(s0.panel); if (sh) shots.push({ ms: Math.round(performance.now() - t0), url: sh.url, box: sh.box }); }
        if (performance.now() - lastFlap > 520) { afkTapSpace(); lastFlap = performance.now(); } // gentle rhythm keeps the bird alive => more bars recorded
        await afkSleep(110);
      }
    } catch (e) {}
    afkLockInput(false);
    afkRecord({ t: Date.now(), ok: false, why: 'flap_unsupported', variant: 'flap', build: VER, steps: 0, ms: Math.round(performance.now() - t0), dot: s0.dot || null, r: s0.r || 0, panel: s0.panel, end: s0.dot || [0, 0], canvas: cv ? [cv.width, cv.height] : null, view: [innerWidth, innerHeight], dpr: window.devicePixelRatio || 1, dump: { flap: true, frames, shots } });
    await afkSleep(3500); _afkFlap = false;
  }

  let _afkTimer = false;
  // Detection poll driven by the worker-backed rAF (not setInterval) so a check that pops while the tab is
  // hidden is noticed within ~1.4s instead of up to a minute later (throttled) - by which point it'd time out.
  function startAfk() {
    if (_afkTimer) return; _afkTimer = true; let last = 0;
    (function poll() {
      const now = performance.now();
      if (now - last > 1400) { last = now; if (get('afk', false) && !_afkSolving && !_afkFlap) { const s = afkFindDot(); if (s && s.active) { if (afkVariant() === 'flap') afkHandleFlap(s); else afkSolve(); } } } // drag check -> afkSolve does the motion-based dot ID
      requestAnimationFrame(poll);
    })();
  }

  // AFK UI: on/off toggle + solve-log box
  function afkToggleEl() {
    const t = document.createElement('div'); t.className = 'fm-toggle'; t.title = 'toggle the AFK auto-solver';
    const draw = () => t.classList.toggle('on', get('afk', false));
    t.onclick = () => { set('afk', !get('afk', false)); draw(); const on = get('afk', false); const sub = document.getElementById('fm-afk-state'); if (sub) sub.textContent = on ? 'ON - solving checks for you' : 'OFF'; toast('AFK auto-solver ' + (on ? 'ON' : 'OFF')); };
    draw(); return t;
  }
  let afklogEl = null;
  function buildAfkLog() {
    afklogEl = document.createElement('div'); afklogEl.id = 'fm-afklog';
    afklogEl.innerHTML = `<div id="fm-afklog-box">
      <div id="fm-afklog-head"><div style="flex:1;font-size:16px">AFK solve log</div><span id="fm-afklog-sum"></span><button class="fm-x" id="fm-afklog-x">✕</button></div>
      <div id="fm-afklog-list"></div></div>`;
    document.body.appendChild(afklogEl);
    afklogEl.addEventListener('mousedown', e => { if (e.target === afklogEl) closeAfkLog(); });
    afklogEl.querySelector('#fm-afklog-x').onclick = closeAfkLog;
  }
  function openAfkLog() { if (!afklogEl) buildAfkLog(); afklogEl.classList.add('open'); renderAfkLog(); }
  function closeAfkLog() { if (afklogEl) afklogEl.classList.remove('open'); }
  function renderAfkLog() {
    if (!afklogEl || !afklogEl.classList.contains('open')) return;
    const s = afkStats(); afklogEl.querySelector('#fm-afklog-sum').textContent = s.total ? (s.ok + '/' + s.total + ' solved · ' + Math.round(s.ok / s.total * 100) + '%') : '';
    const list = afklogEl.querySelector('#fm-afklog-list');
    if (!afkLog.length) { list.innerHTML = '<div class="fm-logempty">No solves yet.<br>Turn the auto-solver ON and go AFK - every check shows up here.</div>'; return; }
    list.innerHTML = afkLog.map(e => {
      const tm = new Date(e.t).toLocaleTimeString();
      if (e.variant === 'flap') {
        const fr = e.dump && e.dump.frames ? e.dump.frames.length : 0;
        return `<div class="fm-logrow flap"><span class="st">⚑ FLAPPY${e.dump ? ' 📎' : ''}</span><span class="meta">unsupported - recorded ${fr} frames over ${(e.ms / 1000).toFixed(1)}s · Export &amp; send to dev</span><span class="tm">${tm}</span></div>`;
      }
      const reason = e.ok ? '' : ' · ' + (e.why || 'unsolved');
      const clip = e.dump ? ' <span title="full capture saved - included in Export">📎</span>' : '';
      const dot = e.dot || [0, 0], end = e.end || [0, 0];
      const meta = `dot [${dot[0]},${dot[1]}] → [${end[0]},${end[1]}] · ${e.steps} steps · ${(e.ms / 1000).toFixed(1)}s${reason}`;
      return `<div class="fm-logrow ${e.ok ? 'ok' : 'bad'}"><span class="st">${e.ok ? '✓ SOLVED' : '✗ failed'}${clip}</span><span class="meta">${meta}</span><span class="tm">${tm}</span></div>`;
    }).join('');
  }

  // menu tabs
  function row(label, sub, control) {
    const r = document.createElement('div'); r.className = 'fm-row';
    const l = document.createElement('div'); l.className = 'lbl'; l.innerHTML = label + (sub ? `<span class="sub">${sub}</span>` : '');
    r.append(l, control); return r;
  }
  function actionBtn(text, fn) { const b = document.createElement('button'); b.className = 'fm-btn'; b.textContent = text; b.onclick = fn; return b; }
  const soon = b => { const d = document.createElement('div'); d.className = 'fm-soon'; d.textContent = 'Coming soon.'; b.appendChild(d); };

  // placeholder tabs show "?"; Info holds the real database browser. id is internal, label is shown.
  const TABS = [
    { id: 'AFK', label: 'AFK Solver', render: b => {
        b.append(
          row('Auto-solve AFK check<span class="sub" id="fm-afk-state">' + (get('afk', false) ? 'ON - solving checks for you' : 'OFF') + '</span>', null, afkToggleEl()),
          row('Solve log<span class="sub" id="fm-afk-sum">' + (afkStats().total ? afkStats().ok + '/' + afkStats().total + ' solved' : 'no solves yet') + '</span>', null, actionBtn('Open', openAfkLog)),
          row("Export logs<span class=\"sub\">for a check it couldn't solve - downloads everything I need to rebuild it</span>", null, actionBtn('Export', afkExport))
        );
        const note = document.createElement('div'); note.className = 'fm-note';
        note.innerHTML = "Drags the circle to the end for you. Works even when the tab is minimized. While solving (~3–5s) your mouse is briefly locked so it can't fight the drag.";
        const warn = document.createElement('div'); warn.className = 'fm-note warn';
        warn.innerHTML = "⚠ Experimental - built and tested on a small set of checks. Unusual variants (e.g. the “flappybird” check) and very large or sharply-curved tunnels may not solve yet. Hit one? Press <b>Export logs</b> and send the file to <b>@kw0d932</b> on Discord - it includes a screenshot + geometry of the check so I can add support.";
        b.append(note, warn);
      } },
    { id: 't2', label: '?', render: soon },
    { id: 't3', label: '?', render: soon },
    { id: 't4', label: '?', render: soon },
    { id: 'Info', label: 'Wiki', render: b => b.append(
        row('Petal database', (loadPetals().length || 118) + ' petals', actionBtn('Browse', () => openDB('petals'))),
        row('Mob database', (loadMobs().length || 73) + ' mobs', actionBtn('Browse', () => openDB('mobs')))) }
  ];

  // toast
  let toastEl;
  function toast(msg) {
    if (!toastEl) { toastEl = document.createElement('div'); toastEl.style.cssText = `position:fixed;left:50%;bottom:80px;transform:translateX(-50%);background:${C.panelDark};color:#fff;-webkit-text-stroke:0.5px #000;paint-order:stroke fill;border:2px solid ${C.panelEdge};border-radius:8px;padding:8px 14px;font-family:'Game','Ubuntu',sans-serif;font-size:13px;z-index:2147483646;transition:opacity .25s;pointer-events:none;`; document.body.appendChild(toastEl); }
    toastEl.textContent = msg; toastEl.style.opacity = '1'; clearTimeout(toastEl._t); toastEl._t = setTimeout(() => toastEl.style.opacity = '0', 1600);
  }

  // about / contact (opened by the info icon in the header)
  let aboutEl = null;
  function buildAbout() {
    aboutEl = document.createElement('div'); aboutEl.id = 'fm-about';
    aboutEl.innerHTML = `<div id="fm-about-box">
      <h3>florr menu<small>v0.5</small></h3>
      <p>A florr.io toolkit.</p>
      <p class="warn">The AFK solver is experimental. It was built and tested on a small set of checks, so unusual variants (like the “flap” check) and very large or sharply-curved tunnels may not solve yet.</p>
      <p>Want a feature, or support for a check it can't handle? Message me on Discord:</p>
      <div class="fm-discord" title="click to copy">💬 @kw0d932 <span class="cp">copy</span></div>
      <div class="fm-warn-btns"><button class="fm-btn gray" id="fm-about-close">Close</button></div>
    </div>`;
    document.body.appendChild(aboutEl);
    aboutEl.addEventListener('mousedown', e => { if (e.target === aboutEl) closeAbout(); });
    aboutEl.querySelector('#fm-about-close').onclick = closeAbout;
    const dc = aboutEl.querySelector('.fm-discord'), cp = dc.querySelector('.cp');
    dc.onclick = () => { try { navigator.clipboard.writeText('@kw0d932'); cp.textContent = 'copied!'; setTimeout(() => cp.textContent = 'copy', 1500); } catch (e) {} };
  }
  function openAbout() { if (!aboutEl) buildAbout(); aboutEl.classList.add('open'); }
  function closeAbout() { if (aboutEl) aboutEl.classList.remove('open'); }

  // menu shell
  function buildMenu() {
    const mismatch = VER && VER !== KNOWN_VERSION;
    const foot = VER ? ('florr build ' + verShort(VER) + (mismatch ? ' ⚠ untested' : '')) : 'florr version unknown';
    const root = document.createElement('div'); root.id = 'fm-root';
    root.innerHTML = `<div id="fm-panel">
        <div id="fm-head"><div id="fm-title">florr menu<small>v0.5</small><span class="fm-i" title="about & contact">i</span></div><button class="fm-x">&#10005;</button></div>
        <div id="fm-tabs"></div><div id="fm-body"></div>
        <div class="fm-note${mismatch ? ' warn' : ''}">${foot}</div>
      </div>`;
    document.body.appendChild(root);

    const tabsEl = root.querySelector('#fm-tabs'), bodyEl = root.querySelector('#fm-body');
    function renderTab(id) {
      const t = TABS.find(x => x.id === id) || TABS[TABS.length - 1];
      set('tab', t.id);
      [...tabsEl.children].forEach(el => el.classList.toggle('on', el.dataset.t === t.id));
      bodyEl.innerHTML = ''; t.render(bodyEl);
    }
    TABS.forEach(t => { const el = document.createElement('div'); el.className = 'fm-tab'; el.dataset.t = t.id; el.textContent = t.label; el.onclick = () => renderTab(t.id); tabsEl.appendChild(el); });
    renderTab(TABS.some(t => t.id === get('tab', 'AFK')) ? get('tab', 'AFK') : 'AFK');

    // launcher: florr's logo (green keyed out of the apple-touch-icon)
    const fab = document.createElement('div'); fab.id = 'fm-fab'; document.body.appendChild(fab);
    (function () {
      const url = (document.querySelector('link[rel="apple-touch-icon"]') || document.querySelector('link[rel~="icon"]') || {}).href || 'https://florr.io/apple-touch-icon.png';
      const setImg = (src, extra) => fab.innerHTML = '<img src="' + src + '" draggable="false" style="width:36px;height:36px;object-fit:contain;pointer-events:none;' + (extra || '') + '">';
      const img = new Image();
      img.onload = function () {
        try {
          const s = img.naturalWidth || 180, cv = document.createElement('canvas'); cv.width = cv.height = s;
          const cx = cv.getContext('2d'); cx.drawImage(img, 0, 0, s, s);
          const d = cx.getImageData(0, 0, s, s), p = d.data, br = p[0], bg = p[1], bb = p[2];
          for (let i = 0; i < p.length; i += 4) { const dr = p[i] - br, dg = p[i + 1] - bg, db = p[i + 2] - bb; if (dr * dr + dg * dg + db * db < 70 * 70) p[i + 3] = 0; }
          cx.putImageData(d, 0, 0); setImg(cv.toDataURL());
        } catch (e) { setImg(url, 'border-radius:8px;'); }
      };
      img.onerror = () => fab.textContent = '🙂'; img.src = url;
    })();

    function show(v) { root.style.display = v ? '' : 'none'; fab.style.display = v ? 'none' : 'flex'; set('open', v); }
    root.querySelector('.fm-x').onclick = () => show(false);
    const iEl = root.querySelector('.fm-i');
    iEl.addEventListener('mousedown', e => e.stopPropagation()); // clicking the icon must not start a header drag
    iEl.onclick = openAbout;
    fab.onclick = () => show(true);
    show(get('open', true));
    window.addEventListener('keydown', e => { if (e.code === 'Backquote' && !e.repeat && (!dbEl || !dbEl.classList.contains('open'))) show(root.style.display === 'none'); });

    const head = root.querySelector('#fm-head'); let dx = 0, dy = 0, drag = false;
    head.addEventListener('mousedown', e => { drag = true; head.classList.add('drag'); dx = e.clientX - root.offsetLeft; dy = e.clientY - root.offsetTop; e.preventDefault(); });
    window.addEventListener('mousemove', e => { if (drag) { root.style.left = (e.clientX - dx) + 'px'; root.style.top = (e.clientY - dy) + 'px'; } });
    window.addEventListener('mouseup', () => { drag = false; head.classList.remove('drag'); });

    startAfk(); // begin watching for AFK checks (only solves when the AFK toggle is ON)
    console.log('[florr menu] v0.5 loaded - build ' + verShort(VER) + (mismatch ? ' (UNTESTED build)' : '') + '. press ` or the logo to toggle. AFK auto-solver ' + (get('afk', false) ? 'ON' : 'OFF') + '.');
  }

  // version gate
  function showVersionWarning(onProceed) {
    const w = document.createElement('div'); w.id = 'fm-warn';
    w.innerHTML = `<div id="fm-warn-box">
      <div class="fm-warn-title">⚠ florr has updated</div>
      <div class="fm-warn-body">This menu was verified on build <b>${verShort(KNOWN_VERSION)}</b>, but florr is now running <b>${verShort(VER)}</b>.<br><br>An update can move the memory offsets and data this menu reads, so features may misbehave - and there's a small chance a new build changes what's detectable. Running on an untested build is at your own risk.</div>
      <div class="fm-warn-btns">
        <button class="fm-btn" id="fm-warn-no">Don't run</button>
        <button class="fm-btn red" id="fm-warn-run">Run anyway</button>
      </div></div>`;
    document.body.appendChild(w);
    w.querySelector('#fm-warn-no').onclick = () => { w.remove(); console.log('[florr menu] not running - version mismatch declined by user.'); };
    w.querySelector('#fm-warn-run').onclick = () => { w.remove(); onProceed(); }; // proceed for this load only; no persisted ack
  }

  // bring up the UI once florr is far enough along (we run at document-start)
  // The AFK hook above is already live; the menu UI + solve loop wait for <body> and the build hash.
  function startMenu() {
    if (document.getElementById('fm-style')) return; // guard against double init
    document.head.appendChild(Object.assign(document.createElement('style'), { id: 'fm-style', textContent: css }));
    // warn EVERY load while the build is untested (no permanent dismissal); saved settings are untouched.
    if (VER && VER !== KNOWN_VERSION) showVersionWarning(buildMenu);
    else buildMenu();
  }
  (function waitReady(n) {
    VER = florrVer(); // florr sets versionHash during load
    if (document.body && document.head && (VER || n > 50)) startMenu(); // wait up to ~7.5s for the build hash
    else setTimeout(() => waitReady(n + 1), 150);
  })(0);
})();