// CyberMax Fun kit: shared browser helpers for the CyberMax games (copied into each game's web/ folder by // scripts/games/sync_kit.py, so every game stays self-contained and sellable on its own). // Counter beacon (game + event + channel only: no cookies, no IDs, nothing personal), daily seed, share, PWA install. // (c) 2026 CyberMax. All rights reserved. export const SITE = 'https://cybermaxtools.com'; export const API = 'https://toys.cybermaxtools.com'; export const EPOCH = Date.UTC(2026, 9, 2); // 2 Oct 2026 = puzzle #1 for every CyberMax daily game /** Today's date in the player's own time zone as YYYY-MM-DD (everyone gets a new puzzle at their local midnight). */ export function today(d = new Date()) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } /** Puzzle number for a YYYY-MM-DD date (#1 = 2026-10-02). */ export function dayNumber(iso) { const [y, m, d] = iso.split('-').map(Number); return Math.round((Date.UTC(y, m - 1, d) - EPOCH) / 864e5) + 1; } export function dateOf(n) { const t = new Date(EPOCH + (n - 1) * 864e5); return t.toISOString().slice(0, 10); } /** 32-bit string hash (FNV-1a) and a small fast PRNG (mulberry32): the same seed gives the same game everywhere. */ export function hash(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } export function rng(seed) { let a = typeof seed === 'string' ? hash(seed) : seed >>> 0; return function () { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Count one event (play, win, share, install, pro, cta). Never breaks the page; skipped on localhost and in tests. */ export function count(game, ev, ch = '') { try { if (typeof location === 'undefined' || /^(localhost|127\.0\.0\.1)$/.test(location.hostname) || navigator.webdriver) return; const u = `${API}/e?t=${encodeURIComponent(game)}&a=${encodeURIComponent(ev)}&c=${encodeURIComponent(String(ch).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40))}`; if (navigator.sendBeacon && navigator.sendBeacon(u)) return; fetch(u, { method: 'POST', keepalive: true, mode: 'no-cors' }).catch(() => {}); } catch { /* never break the game */ } } export function store(key, val) { try { if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null'); localStorage.setItem(key, JSON.stringify(val)); } catch { /* private mode */ } return val; } export function toast(msg, ms = 2400) { let t = document.getElementById('toast'); if (!t) { t = document.createElement('div'); t.id = 'toast'; t.setAttribute('role', 'status'); document.body.appendChild(t); } t.textContent = msg; t.classList.add('on'); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove('on'), ms); } /** The link people share: the canonical game page with UTM tags the site view counter reads. */ export function shareLink(path, channel, game) { const u = new URL(SITE + path); u.searchParams.set('utm_source', channel); u.searchParams.set('utm_medium', 'share'); u.searchParams.set('utm_campaign', `game-${game}`); return u.toString(); } /** * Share a result: native share sheet on phones (text + optional PNG card), else copy to clipboard. * text = Wordle-style text block; path = the game's path on the CyberMax site; blob = optional PNG card. */ export async function share(game, text, path, blob, filename) { const url = shareLink(path, 'share', game); const full = `${text}\n${url}`; if (navigator.share) { try { const file = blob ? new File([blob], filename || `${game}.png`, { type: 'image/png' }) : null; const data = file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full }; await navigator.share(data); count(game, 'share', 'native'); return 'shared'; } catch (e) { if (e && e.name === 'AbortError') return 'cancelled'; } } try { await navigator.clipboard.writeText(full); toast('Result copied. Paste it anywhere.'); } catch { prompt('Copy your result:', full); } count(game, 'share', 'copy'); return 'copied'; } export function intents(game, text, path) { const u = (ch) => shareLink(path, ch, game); return { x: `https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(u('x'))}`, bsky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${text}\n${u('bsky')}`)}`, whatsapp: `https://wa.me/?text=${encodeURIComponent(`${text}\n${u('whatsapp')}`)}`, }; } export function download(blob, filename) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500); } /** * Installable app: registers the service worker (offline play) and shows an "Add to Home Screen" button. * Chrome/Edge/Android: the real install prompt. iPhone/iPad Safari: a short Share → Add to Home Screen hint. * The button only appears after the first finished round (offer(): never on first load) and never once installed. */ export function pwa(game, btn) { if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === 'localhost')) { navigator.serviceWorker.register('sw.js').catch(() => {}); } const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone; if (standalone) { count(game, 'play', 'app'); return { offer() {} }; } let deferred = null; let wanted = false; const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent); window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn && !store(`cmx-install-no:${game}`)) btn.hidden = false; }); window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; }); if (btn) { btn.addEventListener('click', async () => { if (deferred) { deferred.prompt(); const r = await deferred.userChoice.catch(() => null); if (r && r.outcome === 'accepted') btn.hidden = true; else if (r && r.outcome === 'dismissed') { store(`cmx-install-no:${game}`, 1); btn.hidden = true; } // "Not now" is remembered deferred = null; } else if (ios) { toast('Tap the Share button, then "Add to Home Screen".', 5000); count(game, 'install', 'ios-hint'); store(`cmx-install-no:${game}`, 1); } }); } return { offer() { wanted = true; if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false; }, }; } /** Rounded rectangle path for canvas cards. */ export function rr(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png')); /** Theme toggle shared by every page (light/dark, remembered). */ export function themeToggle(btn) { if (!btn) return; btn.addEventListener('click', () => { const r = document.documentElement; const c = r.dataset.theme || 'dark'; // games default to the dark arcade look (fun.css) const n = c === 'dark' ? 'light' : 'dark'; r.dataset.theme = n; store('cm-theme-v', n); try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ } }); } // ------------------------------------------------------------------ first-touch attribution on buy links // First-sale review (29 Oct 2026, docs/ceo/first-sale-2026-10-27.md): the first paid order could be traced to the page // that sold it but not to where the buyer came from. So the first time this browser opens a CyberMax page, one short tag // is kept in localStorage (no cookie, no id, never sent on its own): where it came from (utm_source + utm_campaign, s=, // the referrer's host or "direct") and the landing page, e.g. "ft-youtube-brimbloom-at-games-brimbloom". A Buy click // carries it: s=-- on data.cybermaxtools.com/buy links (the Worker copies s into client_reference_id // "data_"), client_reference_id=-- on buy.stripe.com links. No extra request, page view or Worker call. // The same code is in shared/convert-kit/convert.js (site pages), so the tag is shared across cybermaxtools.com. const FT_KEY = 'cmx-ft'; const FT_DAYS = 90; const ftSlug = (x, n) => String(x || '').toLowerCase().replace(/^www\./, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').slice(0, n).replace(/-+$/, ''); // words collect.py reads as "our own test" (TEST_MARK) never appear in a buyer's tag const ftClean = (t) => t.replace(/(^|-)(test|e2e|check|probe|audit|lane\d*|internal)(?=-|$)/g, '$1x'); export function ftCompute(loc, referrer) { const q = new URLSearchParams(loc.search || ''); const us = q.get('utm_source'), uc = q.get('utm_campaign'), s = q.get('s'); let ch; if (us) ch = ftSlug(us, 14) + (uc ? `-${ftSlug(String(uc).replace(/^\d{4}-\d{2}-\d{2}-(\d+-)?/, ''), 18)}` : ''); // post ids start with a date else if (s) ch = `s-${ftSlug(s, 24)}`; else { let host = ''; try { host = referrer ? new URL(referrer).hostname.toLowerCase() : ''; } catch { host = 'other'; } ch = !host ? 'direct' : /(^|\.)cybermaxtools\.com$|cybermax-tools/.test(host) ? `site-${ftSlug(host.split('.')[0], 12)}` : `r-${ftSlug(host, 24)}`; } const segs = String(loc.pathname || '/').toLowerCase().split('/').filter((x) => x && !/^index\.html?$/.test(x)); const land = ftSlug(segs.slice(-2).join('-'), 18) || 'home'; return ftClean(`ft-${ch || 'direct'}-at-${land}`).slice(0, 48).replace(/-+$/, ''); } /** The first-touch tag of this browser ('' when storage is blocked). Recorded once, kept FT_DAYS days. */ export function firstTouch() { try { const old = JSON.parse(localStorage.getItem(FT_KEY) || 'null'); if (old && old.t && Date.now() - old.at < FT_DAYS * 864e5) return old.t; const t = ftCompute(location, document.referrer); localStorage.setItem(FT_KEY, JSON.stringify({ t, at: Date.now() })); return t; } catch { return ''; } } /** href with the first-touch tag added (data /buy links: s; Stripe links: client_reference_id). Pure, for tests too. */ export function ftTagUrl(href, t) { if (!t) return href; try { const u = new URL(href); if (u.hostname === 'data.cybermaxtools.com' && u.pathname.startsWith('/buy/')) { const s = (u.searchParams.get('s') || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g, ''); if (s.includes('--ft-')) return href; u.searchParams.set('s', `${s}--${t}`.slice(0, 80).replace(/-+$/, '')); // the Worker keeps s to 80 chars of [a-z0-9-] return u.toString(); } if (u.hostname === 'buy.stripe.com') { const c = u.searchParams.get('client_reference_id') || 'data_unknown'; if (c.includes('--ft-')) return href; u.searchParams.set('client_reference_id', `${c}--${t}`.slice(0, 200)); // Stripe: 200 chars of letters, digits, - and _ return u.toString(); } } catch { /* not a URL */ } return href; } if (typeof document !== 'undefined' && typeof location !== 'undefined' && !window.__cmxFt) { window.__cmxFt = 1; firstTouch(); // capture phase, registered before funpass.js/skins.js add their direct-checkout handler (they import this module) document.addEventListener('click', (e) => { const a = e.target && e.target.closest && e.target.closest('a[href*="data.cybermaxtools.com/buy/"],a[href*="buy.stripe.com/"]'); if (a) { const t = firstTouch(); if (t) a.href = ftTagUrl(a.href, t); } }, true); }