florr mod-menu: Full auto AFK-check solver + petal/mob database browser. Support/requests: @kw0d932 on Discord.
// ==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 & 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">✕</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);
})();