// 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) 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; deferred = null; } else if (ios) { toast('Tap the Share button, then "Add to Home Screen".', 5000); count(game, 'install', 'ios-hint'); } }); } 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 || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); const n = c === 'dark' ? 'light' : 'dark'; r.dataset.theme = n; store('cm-theme-v', n); try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ } }); }