// Hushfox safety filter: keeps every deck family-safe. Used by tests/words.test.mjs on the hand-written library and // by the room Worker on every AI-generated category before it is cached or shown. Whole-word match on a normalised // copy (accents stripped, lower case), so "glass" or "Scunthorpe"-style words are not caught by accident. // (c) 2026 CyberMax. All rights reserved. const BLOCK = [ // profanity and insults 'damn', 'hell', 'crap', 'shit', 'fuck', 'fucking', 'bitch', 'bastard', 'ass', 'arse', 'asshole', 'dick', 'cock', 'piss', 'bollocks', 'bugger', 'wanker', 'twat', 'prick', 'slut', 'whore', 'idiot', 'moron', 'stupid', 'retard', 'loser', 'dumb', // sexual 'sex', 'sexy', 'porn', 'nude', 'naked', 'boobs', 'boob', 'breast', 'penis', 'vagina', 'orgasm', 'condom', 'erotic', 'stripper', 'strip club', 'brothel', 'fetish', 'kinky', 'lingerie', 'hooker', 'escort', 'viagra', 'onlyfans', // drugs, alcohol excess, gambling 'cocaine', 'heroin', 'meth', 'weed', 'marijuana', 'cannabis', 'joint', 'bong', 'crack', 'ecstasy', 'lsd', 'drunk', 'hangover', 'vodka shot', 'shots', 'casino', 'gambling', 'cigarette', 'vape', 'tobacco', // violence, weapons and death 'gun', 'rifle', 'pistol', 'shotgun', 'bullet', 'bomb', 'grenade', 'kill', 'killer', 'murder', 'suicide', 'blood', 'gore', 'corpse', 'torture', 'terrorist', 'massacre', 'stab', 'shoot', 'shooting', 'war crime', 'execution', // hate and extremism 'nazi', 'hitler', 'kkk', 'racist', 'slave', 'slavery', 'genocide', 'jihad', ]; const N = (s) => String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); const SET = new Set(BLOCK.map(N)); // Harmless everyday phrases that contain a blocked word. const ALLOW = new Set(['staple gun', 'glue gun', 'water gun', 'pop gun', 'water pistol', 'shooting star', 'bullet train'].map(N)); /** The blocked term found in text, or null. Checks single words and two-word phrases. */ export function blocked(text) { if (ALLOW.has(N(text))) return null; const w = N(text).split(' ').filter(Boolean); for (let i = 0; i < w.length; i++) { if (SET.has(w[i])) return w[i]; if (i + 1 < w.length && SET.has(`${w[i]} ${w[i + 1]}`)) return `${w[i]} ${w[i + 1]}`; } return null; } /** A word (or, with max 40, a category) is acceptable: letters (any language), spaces and simple punctuation, 1-max chars, not blocked. */ export function okEntry(s, max = 32) { const t = String(s || '').trim(); return t.length >= 1 && t.length <= max && /^[\p{L}\p{N}][\p{L}\p{N} '’\-&.]*$/u.test(t) && !blocked(t); } /** Clean an AI answer {category, words[]} -> {cat, words[]} or null. Needs 8+ safe, distinct words. */ export function cleanAi(obj) { if (!obj || typeof obj !== 'object') return null; const cat = String(obj.category || '').trim().replace(/\s+/g, ' '); if (!okEntry(cat, 40)) return null; const seen = new Set(); const words = []; for (const raw of Array.isArray(obj.words) ? obj.words : []) { const w = String(raw || '').trim().replace(/\s+/g, ' ').replace(/^[-*\d.\s]+/, ''); const k = N(w); if (!okEntry(w) || seen.has(k) || k === N(cat)) continue; seen.add(k); words.push(w.charAt(0).toUpperCase() + w.slice(1)); if (words.length >= 16) break; } return words.length >= 8 ? { cat: cat.charAt(0).toUpperCase() + cat.slice(1), words } : null; }