florr menu

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);
})();