cybermax / kenwren /core.js
CyberMax-tools's picture
Deploy kenwren to the hub
49fa989 verified
Raw History Blame Contribute Delete
10.1 kB
// Kenwren core: the question bank and the rules (no DOM). The browser uses it to build and play quizzes; the CyberMax
// toys Worker uses a copy (products/web-toys/worker/src/kenwren-core.js, made by scripts/games/sync_kit.py) to check
// and score every play on the server, so the creator's answers never reach a friend's browser before they finish.
// (c) 2026 CyberMax. All rights reserved.
export const QUIZ_LEN = 10;
export const NAME_MAX = 20;
/** Question bank. `{n}` is replaced with the creator's name when friends play. Packs other than "classic" need the Fun Pass. */
export const QUESTIONS = [
// classic (free)
{ id: 'c01', pack: 'classic', me: 'My perfect weekend is…', you: '{n}\'s perfect weekend is…', opts: ['Out with friends', 'Sofa and a series', 'A trip somewhere new', 'Getting things done'] },
{ id: 'c02', pack: 'classic', me: 'Coffee or tea?', you: 'Coffee or tea for {n}?', opts: ['Coffee, always', 'Tea, obviously', 'Both', 'Neither'] },
{ id: 'c03', pack: 'classic', me: 'My go-to snack is…', you: '{n}\'s go-to snack is…', opts: ['Chips', 'Chocolate', 'Fruit', 'Cheese'] },
{ id: 'c04', pack: 'classic', me: 'Pick my superpower:', you: 'Which superpower would {n} pick?', opts: ['Flying', 'Invisibility', 'Teleporting', 'Reading minds'] },
{ id: 'c05', pack: 'classic', me: 'I am a…', you: '{n} is a…', opts: ['Morning person', 'Night owl', 'Afternoon napper', 'Always tired'] },
{ id: 'c06', pack: 'classic', me: 'My dream holiday is…', you: '{n}\'s dream holiday is…', opts: ['Beach', 'Mountains', 'Big city', 'Road trip'] },
{ id: 'c07', pack: 'classic', me: 'Cats or dogs?', you: 'Cats or dogs for {n}?', opts: ['Cats', 'Dogs', 'Both', 'Neither, fish'] },
{ id: 'c08', pack: 'classic', me: 'On a night out I am…', you: 'On a night out {n} is…', opts: ['First on the dance floor', 'Deep in conversation', 'Ordering more food', 'Home by 11'] },
{ id: 'c09', pack: 'classic', me: 'My favourite season is…', you: '{n}\'s favourite season is…', opts: ['Spring', 'Summer', 'Autumn', 'Winter'] },
{ id: 'c10', pack: 'classic', me: 'Pizza topping I would defend:', you: 'Which pizza topping would {n} defend?', opts: ['Pineapple', 'Pepperoni', 'Mushrooms', 'Just cheese'] },
{ id: 'c11', pack: 'classic', me: 'My phone battery is usually…', you: '{n}\'s phone battery is usually…', opts: ['Above 80%', 'Around half', 'In the red', 'Dead'] },
{ id: 'c12', pack: 'classic', me: 'I would rather…', you: 'Would {n} rather…', opts: ['Sing in public', 'Dance in public', 'Give a speech', 'Stay home'] },
{ id: 'c13', pack: 'classic', me: 'In a group project I am…', you: 'In a group project {n} is…', opts: ['The leader', 'The ideas person', 'The one who does it all', 'Missing'] },
{ id: 'c14', pack: 'classic', me: 'My comfort movie genre:', you: '{n}\'s comfort movie genre:', opts: ['Comedy', 'Horror', 'Romance', 'Action'] },
{ id: 'c15', pack: 'classic', me: 'Texting style:', you: '{n}\'s texting style:', opts: ['Instant reply', 'Replies in 3 days', 'Voice notes only', 'Just emojis'] },
{ id: 'c16', pack: 'classic', me: 'Breakfast for me is…', you: 'Breakfast for {n} is…', opts: ['Big and hot', 'Cereal', 'Just coffee', 'What breakfast?'] },
{ id: 'c17', pack: 'classic', me: 'I would win a game show about…', you: '{n} would win a game show about…', opts: ['Movies and TV', 'Sport', 'Music', 'Random facts'] },
{ id: 'c18', pack: 'classic', me: 'My room is…', you: '{n}\'s room is…', opts: ['Spotless', 'Organised chaos', 'Just chaos', 'Depends on the day'] },
{ id: 'c19', pack: 'classic', me: 'Biggest fear:', you: '{n}\'s biggest fear:', opts: ['Spiders', 'Heights', 'Public speaking', 'The dark'] },
{ id: 'c20', pack: 'classic', me: 'If I won the lottery I would first…', you: 'If {n} won the lottery, first thing:', opts: ['Travel', 'Buy a house', 'Quit my job', 'Tell nobody'] },
// deep (Fun Pass)
{ id: 'd01', pack: 'deep', me: 'What matters most to me:', you: 'What matters most to {n}?', opts: ['Family', 'Freedom', 'Success', 'Fun'] },
{ id: 'd02', pack: 'deep', me: 'When I am stressed I…', you: 'When {n} is stressed they…', opts: ['Talk it out', 'Go quiet', 'Exercise', 'Eat'] },
{ id: 'd03', pack: 'deep', me: 'My love language is…', you: '{n}\'s love language is…', opts: ['Words', 'Time together', 'Gifts', 'Helping out'] },
{ id: 'd04', pack: 'deep', me: 'I make decisions with my…', you: '{n} decides with their…', opts: ['Head', 'Heart', 'Gut', 'Group chat'] },
{ id: 'd05', pack: 'deep', me: 'In ten years I want to be…', you: 'In ten years {n} wants to be…', opts: ['Rich', 'Happy and settled', 'Travelling', 'Famous'] },
{ id: 'd06', pack: 'deep', me: 'I recharge by…', you: '{n} recharges by…', opts: ['Being alone', 'Being with people', 'Being outdoors', 'Sleeping'] },
{ id: 'd07', pack: 'deep', me: 'I would most like to master…', you: '{n} would most like to master…', opts: ['An instrument', 'A language', 'Cooking', 'A sport'] },
{ id: 'd08', pack: 'deep', me: 'My worst habit is…', you: '{n}\'s worst habit is…', opts: ['Being late', 'Overthinking', 'Procrastinating', 'Doom-scrolling'] },
{ id: 'd09', pack: 'deep', me: 'A perfect gift for me is…', you: 'A perfect gift for {n} is…', opts: ['Something handmade', 'An experience', 'Something useful', 'Food'] },
{ id: 'd10', pack: 'deep', me: 'I am happiest when…', you: '{n} is happiest when…', opts: ['Learning something', 'Laughing with friends', 'Achieving a goal', 'Doing nothing'] },
// spicy-but-clean (Fun Pass)
{ id: 's01', pack: 'spicy', me: 'Most likely to go viral for…', you: '{n} is most likely to go viral for…', opts: ['A dance', 'A rant', 'A fail', 'A cooking video'] },
{ id: 's02', pack: 'spicy', me: 'My secret talent is…', you: '{n}\'s secret talent is…', opts: ['Singing', 'Gossip', 'Napping anywhere', 'Winning arguments'] },
{ id: 's03', pack: 'spicy', me: 'I have stalked an ex\'s profile…', you: 'Has {n} stalked an ex\'s profile?', opts: ['Never', 'Once', 'Regularly', 'Right now'] },
{ id: 's04', pack: 'spicy', me: 'Most embarrassing place I fell asleep:', you: 'Where did {n} embarrassingly fall asleep?', opts: ['In class or a meeting', 'On public transport', 'At a party', 'Mid-conversation'] },
{ id: 's05', pack: 'spicy', me: 'I would survive a zombie outbreak for…', you: '{n} would survive a zombie outbreak for…', opts: ['5 minutes', 'A day', 'A month', 'Forever, I am ready'] },
{ id: 's06', pack: 'spicy', me: 'My most used emoji:', you: '{n}\'s most used emoji:', opts: ['😂', '❤️', '💀', '🙄'] },
{ id: 's07', pack: 'spicy', me: 'Who pays on a first date?', you: 'Who pays on a first date, says {n}?', opts: ['I do', 'They do', 'We split', 'Whoever asked'] },
{ id: 's08', pack: 'spicy', me: 'I lie most about…', you: '{n} lies most about…', opts: ['Being on my way', 'Having read the book', 'Being fine', 'Liking the gift'] },
{ id: 's09', pack: 'spicy', me: 'My villain origin story would be…', you: '{n}\'s villain origin story would be…', opts: ['Someone ate my food', 'Slow walkers', 'Spoilers', 'Reply-all emails'] },
{ id: 's10', pack: 'spicy', me: 'Guilty pleasure:', you: '{n}\'s guilty pleasure:', opts: ['Reality TV', 'Fast food at 2 AM', 'Cheesy pop', 'Online shopping'] },
];
export const BY_ID = Object.fromEntries(QUESTIONS.map((q) => [q.id, q]));
export const PACKS = { classic: { name: 'Classic', free: true }, deep: { name: 'Deep', free: false }, spicy: { name: 'Spicy (clean)', free: false } };
/** Clean a display name: letters, digits, spaces and a few marks, trimmed, 1..NAME_MAX chars; '' if empty. */
export function cleanName(s) {
return String(s || '').normalize('NFKC').replace(/[^\p{L}\p{N} '._-]/gu, '').replace(/\s+/g, ' ').trim().slice(0, NAME_MAX);
}
/** Validate a new quiz: { name, items: [{ q, a }] } with QUIZ_LEN distinct known questions and a valid answer each. */
export function checkQuiz(body, pro = false) {
const name = cleanName(body?.name);
if (!name) return { error: 'Add your name first.' };
const items = Array.isArray(body?.items) ? body.items : [];
if (items.length !== QUIZ_LEN) return { error: `A quiz has ${QUIZ_LEN} questions.` };
const seen = new Set();
for (const it of items) {
const q = BY_ID[it?.q];
if (!q || seen.has(q.id)) return { error: 'Unknown or repeated question.' };
if (!pro && !PACKS[q.pack].free) return { error: 'That question pack needs the Fun Pass.' };
if (!Number.isInteger(it.a) || it.a < 0 || it.a >= q.opts.length) return { error: 'Pick an answer for every question.' };
seen.add(q.id);
}
return { name, items: items.map((it) => ({ q: it.q, a: it.a })) };
}
/** Score a friend's picks against the creator's answers. */
export function score(items, picks) {
if (!Array.isArray(picks) || picks.length !== items.length) return null;
let s = 0;
for (let i = 0; i < items.length; i++) {
if (!Number.isInteger(picks[i]) || picks[i] < 0 || picks[i] >= BY_ID[items[i].q].opts.length) return null;
if (picks[i] === items[i].a) s++;
}
return s;
}
/** A fresh suggested quiz: QUIZ_LEN questions from the allowed packs, seeded so "shuffle" is reproducible in tests. */
export function suggest(seed, packs = ['classic']) {
let a = 2166136261;
for (const ch of String(seed)) { a ^= ch.charCodeAt(0); a = Math.imul(a, 16777619); }
const rnd = () => { 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; };
const pool = QUESTIONS.filter((q) => packs.includes(q.pack)).map((q) => q.id);
for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; }
return pool.slice(0, QUIZ_LEN);
}
export function verdict(s) {
return s >= 10 ? 'Soulmate level. You know them better than they do.' : s >= 8 ? 'Best friend energy.' : s >= 6 ? 'Good friend. Room to grow.' : s >= 4 ? 'Acquaintance vibes.' : 'Have you two actually met?';
}