hushfox / cmx.js
CyberMax-tools's picture
Deploy hushfox
8a273a6 verified
Raw History Blame Contribute Delete
12 kB
// 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=<source>--<tag> on data.cybermaxtools.com/buy links (the Worker copies s into client_reference_id
// "data_<s>"), client_reference_id=<id>--<tag> 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);
}